@ultimat3/cli 5.0.1 → 7.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 +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
package/src/cmd-test.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// `x test`'s command surface: the flags and the one positional it accepts, and the refusals that
|
|
2
2
|
// happen before a single process starts. Which files run is test-select.ts, how they are split and
|
|
3
3
|
// spawned is test-shards.ts — this file only turns argv into their inputs, so a parsing bug can
|
|
4
|
-
// never be read as a sharding one.
|
|
4
|
+
// never be read as a sharding one. `--affected` is the one narrowing decided here rather than
|
|
5
|
+
// there, because it is a fact about a git diff and not about a path: what the diff touches is
|
|
6
|
+
// `affected.ts`, and this file only maps that answer onto the paths discovery yields.
|
|
5
7
|
|
|
8
|
+
import type { AffectedScope } from './affected';
|
|
9
|
+
import { affectedScope, affectedScopeJson, DEFAULT_BASE, inScope } from './affected';
|
|
6
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { ok } from './command';
|
|
7
12
|
import { BadFlagError, NoTestFilesError } from './errors';
|
|
8
13
|
import { readIntFlag } from './flag-number';
|
|
9
|
-
import
|
|
14
|
+
import { msg } from './messages';
|
|
15
|
+
import type { CommandResult, JsonValue } from './output';
|
|
10
16
|
import type { ParsedArgs } from './parse';
|
|
11
|
-
import { flagString } from './parse';
|
|
17
|
+
import { flagBool, flagString } from './parse';
|
|
12
18
|
import { quoteArg } from './shell-quote';
|
|
13
19
|
import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
|
|
14
20
|
import { runShards } from './test-shards';
|
|
@@ -53,12 +59,52 @@ function readOnlyType(positionals: readonly string[]): TestType | undefined {
|
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `--affected`, and the two flags that only mean something with it. The scope itself is
|
|
64
|
+
* `affected.ts`'s — `x affected` reports exactly what this narrows to, or the two commands would
|
|
65
|
+
* be two answers to one question and only one of them would be the one an agent trusts.
|
|
66
|
+
*/
|
|
67
|
+
async function readAffectedScope(ctx: CommandContext): Promise<AffectedScope | undefined> {
|
|
68
|
+
if (flagBool(ctx.args, 'affected')) {
|
|
69
|
+
return affectedScope({ runner: ctx.runner, cwd: ctx.cwd, args: ctx.args, command: 'test' });
|
|
70
|
+
}
|
|
71
|
+
// A flag that parses and changes nothing is a promise `x help test` cannot keep: without
|
|
72
|
+
// `--affected` the whole suite runs, and a `--base` on the line would read as if it had not.
|
|
73
|
+
const idle = flagString(ctx.args, 'base') !== undefined ? 'base' : 'dirty';
|
|
74
|
+
if (flagString(ctx.args, 'base') !== undefined || flagBool(ctx.args, 'dirty')) {
|
|
75
|
+
throw new BadFlagError({
|
|
76
|
+
flag: idle,
|
|
77
|
+
command: 'test',
|
|
78
|
+
reason: 'only narrows a run together with --affected, and on its own it changes nothing',
|
|
79
|
+
fix: `x test --affected --${idle}${idle === 'base' ? ` ${DEFAULT_BASE}` : ''}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The cast is guarded by the three lines above it and is the narrowing TS will not do on its own:
|
|
86
|
+
// `Array.isArray` is declared `value is any[]`, which does not remove `readonly JsonValue[]` from
|
|
87
|
+
// the union, so every branch here still carries the array arm however the check is written.
|
|
88
|
+
const asObject = (value: JsonValue | undefined): Readonly<Record<string, JsonValue>> =>
|
|
89
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
90
|
+
? (value as Readonly<Record<string, JsonValue>>)
|
|
91
|
+
: {};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The scope, carried onto whatever the shards reported. `--json` is what an agent reads, and a
|
|
95
|
+
* narrowed run that does not say what it narrowed to is indistinguishable from a full one.
|
|
96
|
+
*/
|
|
97
|
+
const withScope = (result: CommandResult, scope: AffectedScope): CommandResult => ({
|
|
98
|
+
...result,
|
|
99
|
+
data: { ...asObject(result.data), affected: affectedScopeJson(scope) },
|
|
100
|
+
});
|
|
101
|
+
|
|
56
102
|
export const testCommand: CliCommand = {
|
|
57
103
|
spec: {
|
|
58
104
|
name: 'test',
|
|
59
105
|
summary:
|
|
60
106
|
'run one test type — or the whole suite — across N processes, one isolated database per worker',
|
|
61
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
|
|
107
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
62
108
|
positionalChoices: TEST_TYPES,
|
|
63
109
|
flags: [
|
|
64
110
|
{
|
|
@@ -78,17 +124,53 @@ export const testCommand: CliCommand = {
|
|
|
78
124
|
summary:
|
|
79
125
|
'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
|
|
80
126
|
},
|
|
127
|
+
{
|
|
128
|
+
name: 'affected',
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
summary: 'only the workspaces a diff touches, and everything that depends on one of them',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'base',
|
|
134
|
+
type: 'string',
|
|
135
|
+
summary: `--affected: git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'dirty',
|
|
139
|
+
type: 'boolean',
|
|
140
|
+
summary:
|
|
141
|
+
'--affected: also count uncommitted work, whichever agent in this checkout made it',
|
|
142
|
+
},
|
|
81
143
|
],
|
|
82
144
|
},
|
|
83
145
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
84
146
|
const type = readOnlyType(ctx.args.positionals);
|
|
85
147
|
const filter = flagString(ctx.args, 'filter');
|
|
86
148
|
const sample = readSample(ctx.args);
|
|
149
|
+
const scope = await readAffectedScope(ctx);
|
|
87
150
|
const discovered = await discoverTests(ctx.cwd, filter, type);
|
|
88
151
|
if (discovered.length === 0) {
|
|
89
152
|
throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
|
|
90
153
|
}
|
|
91
|
-
const
|
|
154
|
+
const selected =
|
|
155
|
+
scope === undefined
|
|
156
|
+
? discovered
|
|
157
|
+
: discovered.filter((file) => inScope(file.path, scope.prefixes));
|
|
158
|
+
if (scope !== undefined && selected.length === 0) {
|
|
159
|
+
// Green, and it spawns nothing — a `.md`-only diff genuinely re-checks nothing, and failing
|
|
160
|
+
// a build for editing a doc is the wrong answer. It never reads as "the suite passed": the
|
|
161
|
+
// summary counts the files that ran (zero) and `data.affected` names the diff it asked about,
|
|
162
|
+
// so a caller can always tell "green because nothing is affected" from "green because
|
|
163
|
+
// everything passed". Nothing reaches `runShards`, whose empty file list would be a
|
|
164
|
+
// `bun test` with no arguments — that is, the whole suite.
|
|
165
|
+
return ok('test', msg('cli.test.affected.none', { base: scope.selection.base }), {
|
|
166
|
+
data: {
|
|
167
|
+
...(type === undefined ? {} : { type }),
|
|
168
|
+
files: 0,
|
|
169
|
+
affected: affectedScopeJson(scope),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
92
174
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
93
175
|
const workers = Math.max(1, Math.min(requested, files.length));
|
|
94
176
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
@@ -99,7 +181,7 @@ export const testCommand: CliCommand = {
|
|
|
99
181
|
reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
|
|
100
182
|
});
|
|
101
183
|
}
|
|
102
|
-
|
|
184
|
+
const result = await runShards({
|
|
103
185
|
root: ctx.cwd,
|
|
104
186
|
runner: ctx.runner,
|
|
105
187
|
files,
|
|
@@ -108,7 +190,14 @@ export const testCommand: CliCommand = {
|
|
|
108
190
|
...(filter === undefined ? {} : { filter }),
|
|
109
191
|
...(type === undefined ? {} : { type }),
|
|
110
192
|
// `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
|
|
111
|
-
|
|
193
|
+
// `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
194
|
+
// set, and reporting the whole tree as its total would name a corpus no run ever had.
|
|
195
|
+
...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
|
|
196
|
+
// The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
|
|
197
|
+
// corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
|
|
198
|
+
// thing `reproduceFor` exists to prevent.
|
|
199
|
+
...(scope === undefined ? {} : { affected: scope.selection }),
|
|
112
200
|
});
|
|
201
|
+
return scope === undefined ? result : withScope(result, scope);
|
|
113
202
|
},
|
|
114
203
|
};
|
package/src/cmd-verify.ts
CHANGED
|
@@ -3,438 +3,21 @@
|
|
|
3
3
|
// shippable (axiom 5): one step list, no second checklist, no CI-only step, and no way to narrow
|
|
4
4
|
// the run — `--only` and `--skip` would make "green" mean whatever the caller chose.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
9
|
-
import type { Manifest } from '@ultimat3/manifest';
|
|
10
|
-
import {
|
|
11
|
-
AGENTS_MD_FILENAME,
|
|
12
|
-
assertNoDrift,
|
|
13
|
-
MANIFEST_FILENAME,
|
|
14
|
-
verifyContract,
|
|
15
|
-
} from '@ultimat3/manifest';
|
|
16
|
-
import type { MetaIssue } from '@ultimat3/seo';
|
|
17
|
-
import { validateMeta } from '@ultimat3/seo';
|
|
18
|
-
import { checkAgentsMd } from './app-agents-md';
|
|
19
|
-
import { checkAppBoundaries } from './app-boundaries';
|
|
20
|
-
import { envExampleFindings } from './app-env';
|
|
21
|
-
import { appManifest, readAppManifest } from './app-manifest';
|
|
22
|
-
import { OPENAPI_FILE, openApiJson } from './app-openapi';
|
|
23
|
-
import { APP_CONFIG_FILE, requireAppRoot } from './app-root';
|
|
24
|
-
import { checkBudgets, readBuildStats } from './budgets';
|
|
6
|
+
import { requireAppRoot } from './app-root';
|
|
25
7
|
import type { CliCommand, CommandContext } from './command';
|
|
26
|
-
import { checkDestructiveMigrations } from './db-destructive';
|
|
27
|
-
import { checkDocumentStyles, documentSurfaces } from './document-styles';
|
|
28
|
-
import { checkSourceDrift } from './drift';
|
|
29
|
-
import { checkErrorFixReport } from './error-contract';
|
|
30
8
|
import { readIntFlag } from './flag-number';
|
|
31
|
-
import {
|
|
32
|
-
import { msg } from './messages';
|
|
33
|
-
import type { CommandResult, Finding, StepResult } from './output';
|
|
34
|
-
import { findingFrom } from './output';
|
|
9
|
+
import type { CommandResult } from './output';
|
|
35
10
|
import type { ParsedArgs } from './parse';
|
|
36
|
-
import { scanSiteMeta } from './seo-meta';
|
|
37
11
|
import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
readVerifyFloor,
|
|
42
|
-
skippedSuiteFinding,
|
|
43
|
-
vanishedSuiteFinding,
|
|
44
|
-
} from './verify-floor';
|
|
45
|
-
import type { StepOutcome, VerifyContext, VerifyStep, VerifyStepName } from './verify-step';
|
|
46
|
-
import { fromExec, fromFindings, hostFindings } from './verify-step';
|
|
47
|
-
import { TEST_STEPS } from './verify-tests';
|
|
48
|
-
import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
|
|
12
|
+
import { VERIFY_STEPS } from './verify-checks';
|
|
13
|
+
import { runVerify } from './verify-run';
|
|
14
|
+
import type { VerifyStepName } from './verify-step';
|
|
49
15
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export
|
|
55
|
-
{
|
|
56
|
-
name: 'typecheck',
|
|
57
|
-
summary: 'tsc -b across every project the root references',
|
|
58
|
-
async run(ctx) {
|
|
59
|
-
const result = await ctx.runner(['bunx', 'tsc', '-b', '--pretty', 'false'], {
|
|
60
|
-
cwd: ctx.root,
|
|
61
|
-
});
|
|
62
|
-
return fromExec(result, {
|
|
63
|
-
code: 'X_TYPECHECK_FAILED',
|
|
64
|
-
cause: 'the project does not typecheck',
|
|
65
|
-
fix: 'bunx tsc -b --pretty false',
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: 'lint',
|
|
71
|
-
// Only what biome actually enforces. It claimed "no default exports" while the rule was off
|
|
72
|
-
// (it is not in `recommended`) and "no raw colours" over a file type biome ignores entirely —
|
|
73
|
-
// two thirds of the line were enforced by nothing. `noDefaultExport` is now on in `biome.json`;
|
|
74
|
-
// the colour rule is `packages/ui/src/tokens/tokens.test.ts`, and rides on the `unit` step.
|
|
75
|
-
summary: 'biome: format, no any, no default exports, no unused imports',
|
|
76
|
-
async run(ctx) {
|
|
77
|
-
const result = await ctx.runner(['bunx', 'biome', 'check', '.'], { cwd: ctx.root });
|
|
78
|
-
return fromExec(result, {
|
|
79
|
-
code: 'X_LINT_FAILED',
|
|
80
|
-
cause: 'biome reported problems',
|
|
81
|
-
fix: 'bunx biome check --write .',
|
|
82
|
-
});
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
name: 'boundaries',
|
|
87
|
-
summary: "surface, layer and package-tier imports, and the app's own guards",
|
|
88
|
-
// An app's `guards/` rides here rather than becoming an eighteenth step, for the reason the
|
|
89
|
-
// seam already states: a host adds findings to a step, it can never add, remove, reorder or
|
|
90
|
-
// skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
|
|
91
|
-
// already carries "rules this repo makes about itself that the framework cannot know" (the
|
|
92
|
-
// monorepo's tier table arrives through it), and it runs third, before any suite, so a
|
|
93
|
-
// convention failure is reported in seconds rather than after the tests.
|
|
94
|
-
//
|
|
95
|
-
// Discovered, not registered: `guardFindings` reads the directory. A guard that had to
|
|
96
|
-
// announce itself is a guard an app can forget to announce, which is the coupling axiom 8's
|
|
97
|
-
// extension model rejects.
|
|
98
|
-
run: async (ctx) =>
|
|
99
|
-
fromFindings([
|
|
100
|
-
...(await checkAppBoundaries(ctx.root)),
|
|
101
|
-
...(await guardFindings(ctx.root)),
|
|
102
|
-
...(await hostFindings(ctx, 'boundaries')),
|
|
103
|
-
]),
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
name: 'filesize',
|
|
107
|
-
summary: 'one file, one job',
|
|
108
|
-
run: async (ctx) => fromFindings(await checkFileSizes(ctx.root)),
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
name: 'package-shape',
|
|
112
|
-
summary: 'every package ships the same contract files',
|
|
113
|
-
applies: (ctx) => hasWorkspacePackages(ctx.root),
|
|
114
|
-
run: async (ctx) => fromFindings(await checkPackageShape(ctx.root)),
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: 'errors',
|
|
118
|
-
summary: 'every X_* code has a runnable fix and a docs page',
|
|
119
|
-
// The fix-line half runs anywhere source does. The docs half needs a reference page to check
|
|
120
|
-
// against, and which file that is belongs to the host repo — hence `hostFindings`.
|
|
121
|
-
//
|
|
122
|
-
// The coverage line rides in `output`, which `--json` carries verbatim: a scan without a
|
|
123
|
-
// parser cannot read every fix, and a step that reports only findings claims a completeness
|
|
124
|
-
// it does not have. "checked 412, could not read 27" is what a reader can act on.
|
|
125
|
-
async run(ctx) {
|
|
126
|
-
const report = await checkErrorFixReport(ctx.root);
|
|
127
|
-
const findings = [...report.findings, ...(await hostFindings(ctx, 'errors'))];
|
|
128
|
-
return {
|
|
129
|
-
...fromFindings(findings),
|
|
130
|
-
output: msg('cli.verify.fixCoverage', {
|
|
131
|
-
checked: report.checked,
|
|
132
|
-
unreadable: report.unreadable,
|
|
133
|
-
}),
|
|
134
|
-
};
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
...TEST_STEPS,
|
|
138
|
-
{
|
|
139
|
-
name: 'drift',
|
|
140
|
-
summary: 'schema source vs migrations, and every destructive statement declared',
|
|
141
|
-
// Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
|
|
142
|
-
// Source, not database: the gate runs in CI with nothing listening, and the database half is
|
|
143
|
-
// the post-migrate verification `runMigrations` performs where a connection is already open.
|
|
144
|
-
//
|
|
145
|
-
// The destructive rail rides here rather than becoming an eighteenth step because it asks this
|
|
146
|
-
// step's own question — do the committed migrations still describe what the app is doing to its
|
|
147
|
-
// schema? — off the same directory, in the same pass, with no database either.
|
|
148
|
-
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
149
|
-
run: async (ctx) =>
|
|
150
|
-
fromFindings([
|
|
151
|
-
...(await checkSourceDrift(ctx.root)),
|
|
152
|
-
...(await checkDestructiveMigrations(ctx.root)),
|
|
153
|
-
]),
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
name: 'contract-diff',
|
|
157
|
-
summary: 'the published contract vs the committed manifest',
|
|
158
|
-
// Either file is a published contract on its own: `openapi.json` generates the typed client,
|
|
159
|
-
// so gating on the manifest alone let a stale spec ship a wrong client unchecked.
|
|
160
|
-
applies: async (ctx) =>
|
|
161
|
-
existsSync(join(ctx.root, MANIFEST_FILENAME)) || existsSync(join(ctx.root, OPENAPI_FILE)),
|
|
162
|
-
async run(ctx) {
|
|
163
|
-
const committed = await readAppManifest(ctx.root);
|
|
164
|
-
const { manifest, findings } = await appManifest(ctx.root);
|
|
165
|
-
return fromFindings([
|
|
166
|
-
...findings,
|
|
167
|
-
...(committed === undefined ? [] : contractFindings(committed, manifest)),
|
|
168
|
-
...(await specFindings(ctx.root, manifest)),
|
|
169
|
-
]);
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
name: 'budgets',
|
|
174
|
-
summary: 'per-route JS bytes and LCP, and the global style layer every document carries',
|
|
175
|
-
// The global-style assertion rides here rather than becoming an eighteenth step, because this
|
|
176
|
-
// step already asks the one question it asks: what does the document this build emits actually
|
|
177
|
-
// contain? It is also the same app load — `appManifest` fills render's stylesheet registry on
|
|
178
|
-
// its way through — so a separate step would pay for a second one to answer half a question.
|
|
179
|
-
//
|
|
180
|
-
// A repo with no `app.config.ts` is the framework monorepo, which renders no documents and has
|
|
181
|
-
// no stylesheet registry to read; there is nothing for either half to weigh.
|
|
182
|
-
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
183
|
-
async run(ctx) {
|
|
184
|
-
// No stats file is NOT "nothing to weigh" — it is every declared budget unmeasured, which is
|
|
185
|
-
// the case `checkBudgets` already names per route (`X_BUDGET_UNMEASURED`). Skipping the half
|
|
186
|
-
// entirely is how a step that has never run once reported green: `.x/` is gitignored, so no
|
|
187
|
-
// CI run and neither gated app has ever had a `build-stats.json` for it to read.
|
|
188
|
-
//
|
|
189
|
-
// Handed over as `undefined` and never as `?? { routes: [] }`: "no build has run here" and
|
|
190
|
-
// "a build ran and could not weigh this route" are two different instructions, and only the
|
|
191
|
-
// caller knows which of them is true. The step still does not BUILD — measuring here would
|
|
192
|
-
// make `x verify` a static build on every run (8.2s on `dummy/social-media-clone`, and 5.9s
|
|
193
|
-
// to a hard `X_PRERENDER_FAILED` on `examples/dummy`), and it would be a second builder
|
|
194
|
-
// beside `apps/web/prerender.ts`, which is where an app reads `SITE_ORIGIN`.
|
|
195
|
-
const stats = await readBuildStats(ctx.root);
|
|
196
|
-
// The load's own findings, FIRST and never dropped. A module that would not import registers
|
|
197
|
-
// no route, so its budget is missing from the manifest and every route it declared reads as
|
|
198
|
-
// `X_BUDGET_UNMEASURED` — the symptom, pointing the reader at `x build` for a file that will
|
|
199
|
-
// not compile. `contract-diff` reports these too when it applies; two red steps naming one
|
|
200
|
-
// broken module is honest, and one of them silently green over it is the false green.
|
|
201
|
-
const { manifest, findings } = await appManifest(ctx.root);
|
|
202
|
-
return fromFindings([
|
|
203
|
-
...findings,
|
|
204
|
-
...checkDocumentStyles(documentSurfaces()),
|
|
205
|
-
...checkBudgets(manifest, stats),
|
|
206
|
-
]);
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
name: 'seo',
|
|
211
|
-
summary: 'every indexable site/ route has a title and a description a search result can render',
|
|
212
|
-
// The SEO checkers shipped in `@ultimat3/seo` with no caller anywhere — `validateMeta` and its
|
|
213
|
-
// asserts were reachable only by an app that called them itself, which is what
|
|
214
|
-
// `packages/seo/src/errors.ts`'s own header said. This is the caller.
|
|
215
|
-
//
|
|
216
|
-
// Its own step rather than a rider on `budgets`: that step asks what a document WEIGHS and
|
|
217
|
-
// this one asks what it SAYS, and a missing `<title>` reported under `budgets` would hand the
|
|
218
|
-
// reader a fix for the wrong question (axiom 4). It costs no second app load — `loadApp`
|
|
219
|
-
// imports each module once per process, so this runs on the registries `budgets` just filled.
|
|
220
|
-
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
221
|
-
async run(ctx) {
|
|
222
|
-
const scan = await scanSiteMeta(ctx.root);
|
|
223
|
-
// No `baseUrl`: an app declares no base URL anywhere (`packages/core/src/config.ts` has no
|
|
224
|
-
// such key), so canonical checks are skipped rather than run against an origin this file
|
|
225
|
-
// invented. `seo-meta.ts` spells out why that is the honest half.
|
|
226
|
-
const report = validateMeta(scan.records);
|
|
227
|
-
return {
|
|
228
|
-
ok: scan.findings.length === 0 && report.ok,
|
|
229
|
-
findings: [...scan.findings, ...report.issues.map(seoFinding)],
|
|
230
|
-
};
|
|
231
|
-
},
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
name: 'manifest',
|
|
235
|
-
summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
|
|
236
|
-
// No `applies`. The drift half has nothing to compare against until `x manifest` has run
|
|
237
|
-
// once, and says so by finding nothing — but `AGENTS.md` is required of every repo the gate
|
|
238
|
-
// runs in, so the step always has a question to answer and must never report as skipped.
|
|
239
|
-
//
|
|
240
|
-
// `.env.example` joins this step rather than becoming an eighteenth: the question is the same
|
|
241
|
-
// one — "does a committed, generated file still describe the code?" — and the step list is the
|
|
242
|
-
// definition of shippable, so it grows only when a genuinely new question needs asking.
|
|
243
|
-
//
|
|
244
|
-
// `x.verify.json` is here for that same question and no other: this step judges the floor
|
|
245
|
-
// FILE, `runVerify` judges the suites against it. A name the gate does not run can never
|
|
246
|
-
// vanish, so a typo in the floor covers nothing — which is the false green the floor exists to
|
|
247
|
-
// close, and it is only visible if something reads the file for its own sake.
|
|
248
|
-
async run(ctx) {
|
|
249
|
-
const agents = await checkAgentsMd(ctx.root);
|
|
250
|
-
const findings = [
|
|
251
|
-
...(await driftFindings(ctx.root)),
|
|
252
|
-
...(await envExampleFindings(ctx.root)),
|
|
253
|
-
...floorProblemFindings(await readVerifyFloor(ctx.root)),
|
|
254
|
-
...agents.findings,
|
|
255
|
-
...(await hostFindings(ctx, 'manifest')),
|
|
256
|
-
];
|
|
257
|
-
// Warnings are not findings: `AGENTS.md` tabulating a route table is a smell a human
|
|
258
|
-
// judges, not a build error. They ride in `output`, which `--json` carries verbatim.
|
|
259
|
-
const output = agents.warnings.map((warning) => `${AGENTS_MD_FILENAME}: ${warning}`);
|
|
260
|
-
return {
|
|
261
|
-
ok: findings.length === 0,
|
|
262
|
-
findings,
|
|
263
|
-
...(output.length === 0 ? {} : { output: output.join('\n') }),
|
|
264
|
-
};
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
{
|
|
268
|
-
name: 'roadmap',
|
|
269
|
-
summary: "every roadmap milestone's status marker matches what is actually on disk",
|
|
270
|
-
// A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so
|
|
271
|
-
// the FILE is what decides. It keyed on `ctx.hostChecks?.roadmap` until `As of 2026-08`, which
|
|
272
|
-
// is a fact about the CALL: a caller of the exported `runVerify(VERIFY_STEPS, ctx)` passing no
|
|
273
|
-
// `hostChecks`, in a repo whose committed `x.verify.json` names `roadmap`, got
|
|
274
|
-
// `X_VERIFY_SUITE_VANISHED` — whose `fix:` is the command that had just failed.
|
|
275
|
-
applies: async (ctx) => existsSync(join(ctx.root, ROADMAP_FILE)),
|
|
276
|
-
run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')),
|
|
277
|
-
},
|
|
278
|
-
];
|
|
279
|
-
|
|
280
|
-
/** `assertNoDrift` throws `X_MANIFEST_DRIFT`; a step reports, so the error becomes a finding. */
|
|
281
|
-
async function driftFindings(root: string): Promise<readonly Finding[]> {
|
|
282
|
-
const path = join(root, MANIFEST_FILENAME);
|
|
283
|
-
if (!existsSync(path)) return [];
|
|
284
|
-
const { manifest, findings } = await appManifest(root);
|
|
285
|
-
try {
|
|
286
|
-
await assertNoDrift({ manifest, path });
|
|
287
|
-
return findings;
|
|
288
|
-
} catch (error) {
|
|
289
|
-
return [...findings, { ...findingFrom(error), at: MANIFEST_FILENAME }];
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** A breaking change is allowed — with a major bump. `verifyContract` is the one that decides. */
|
|
294
|
-
function contractFindings(before: Manifest, after: Manifest): readonly Finding[] {
|
|
295
|
-
try {
|
|
296
|
-
verifyContract({ before, after });
|
|
297
|
-
return [];
|
|
298
|
-
} catch (error) {
|
|
299
|
-
return [{ ...findingFrom(error), at: MANIFEST_FILENAME }];
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** The typed client is generated from `openapi.json`, so a stale spec ships a wrong client. */
|
|
304
|
-
async function specFindings(root: string, manifest: Manifest): Promise<readonly Finding[]> {
|
|
305
|
-
const path = join(root, OPENAPI_FILE);
|
|
306
|
-
if (!existsSync(path)) return [];
|
|
307
|
-
if ((await Bun.file(path).text()) === openApiJson(manifest)) return [];
|
|
308
|
-
return [
|
|
309
|
-
{
|
|
310
|
-
code: 'X_MANIFEST_STALE',
|
|
311
|
-
cause: `${OPENAPI_FILE} does not match the actions the code registers`,
|
|
312
|
-
fix: 'x manifest',
|
|
313
|
-
docs: 'https://ultimate.dev/errors/X_MANIFEST_STALE',
|
|
314
|
-
at: OPENAPI_FILE,
|
|
315
|
-
},
|
|
316
|
-
];
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Run every step in order, never bailing early: an agent fixing three things at once needs all
|
|
321
|
-
* three findings from one run, not one per round-trip.
|
|
322
|
-
*/
|
|
323
|
-
export async function runVerify(
|
|
324
|
-
steps: readonly VerifyStep[],
|
|
325
|
-
ctx: VerifyContext,
|
|
326
|
-
): Promise<CommandResult> {
|
|
327
|
-
const floor = await readVerifyFloor(ctx.root);
|
|
328
|
-
const results: StepResult[] = [];
|
|
329
|
-
for (const step of steps) {
|
|
330
|
-
const applies = step.applies === undefined ? true : await step.applies(ctx);
|
|
331
|
-
if (!applies) {
|
|
332
|
-
// A skip this repo already ruled out is not a skip. The step ran here before — the floor is
|
|
333
|
-
// that claim, committed — so "nothing to check" now means the suite was deleted, and the
|
|
334
|
-
// gate says so on the step's own line rather than counting one more thing not to worry
|
|
335
|
-
// about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
|
|
336
|
-
// the summary, `data.failed`, and the reference-app gate's own red list.
|
|
337
|
-
const required = floorRequires(floor, step.name);
|
|
338
|
-
results.push({
|
|
339
|
-
name: step.name,
|
|
340
|
-
ok: !required,
|
|
341
|
-
durationMs: 0,
|
|
342
|
-
skipped: !required,
|
|
343
|
-
findings: required ? [vanishedSuiteFinding(step.name)] : [],
|
|
344
|
-
});
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
const started = performance.now();
|
|
348
|
-
const outcome = await step.run(ctx).catch(
|
|
349
|
-
(error: unknown): StepOutcome => ({
|
|
350
|
-
ok: false,
|
|
351
|
-
findings: [findingOf(error, step.name)],
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
354
|
-
// A step the floor requires whose suite executed nothing is the same vanished suite as a step
|
|
355
|
-
// with no files at all — the run just had to finish before it could be seen. Appended to the
|
|
356
|
-
// step's own findings so `data.failed`, the counts and every gate reading this table carry it.
|
|
357
|
-
const vanished =
|
|
358
|
-
floorRequires(floor, step.name) && outcome.tests !== undefined && outcome.tests.ran === 0
|
|
359
|
-
? [skippedSuiteFinding(step.name, outcome.tests.skipped)]
|
|
360
|
-
: [];
|
|
361
|
-
results.push({
|
|
362
|
-
name: step.name,
|
|
363
|
-
ok: outcome.ok && vanished.length === 0,
|
|
364
|
-
durationMs: Math.round(performance.now() - started),
|
|
365
|
-
findings: [...outcome.findings, ...vanished],
|
|
366
|
-
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
367
|
-
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
|
|
371
|
-
const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
|
|
372
|
-
const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
|
|
373
|
-
return {
|
|
374
|
-
ok: failedSteps.length === 0,
|
|
375
|
-
command: 'verify',
|
|
376
|
-
summary: verifySummary({ results, failed: failedSteps, skipped: skippedSteps, totalMs }),
|
|
377
|
-
steps: results,
|
|
378
|
-
// `skipped` is a list beside `failed` and not a count, because the two answer the same kind of
|
|
379
|
-
// question — *which* steps, not how many — and a caller ratcheting on coverage needs the names.
|
|
380
|
-
data: { failed: failedSteps, skipped: skippedSteps, durationMs: totalMs },
|
|
381
|
-
exitCode: failedSteps.length === 0 ? 0 : 1,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* What the counts are allowed to claim. A step that does not apply is recorded green so the run
|
|
387
|
-
* continues, and the summary counted it among the "all 17 steps passed" — so a repo whose `job`
|
|
388
|
-
* and `eval` suites do not exist reported the same line as a repo where both ran. `--json` carried
|
|
389
|
-
* the per-step flag all along; the one line every reader actually sees did not, which is how a
|
|
390
|
-
* vacuous gate stayed invisible. It names the skipped steps, not just how many: "17/17" is worth
|
|
391
|
-
* something only when the gap is visible in the same glance.
|
|
392
|
-
*/
|
|
393
|
-
function verifySummary(input: {
|
|
394
|
-
readonly results: readonly StepResult[];
|
|
395
|
-
readonly failed: readonly string[];
|
|
396
|
-
readonly skipped: readonly string[];
|
|
397
|
-
readonly totalMs: number;
|
|
398
|
-
}): string {
|
|
399
|
-
const params = {
|
|
400
|
-
count: input.results.length,
|
|
401
|
-
passed: input.results.filter((step) => step.ok && step.skipped !== true).length,
|
|
402
|
-
failed: input.failed.length,
|
|
403
|
-
skipped: input.skipped.length,
|
|
404
|
-
names: input.skipped.join(', '),
|
|
405
|
-
ms: input.totalMs,
|
|
406
|
-
};
|
|
407
|
-
const clean = input.skipped.length === 0;
|
|
408
|
-
if (input.failed.length === 0) {
|
|
409
|
-
return msg(clean ? 'cli.verify.pass' : 'cli.verify.passSkipped', params);
|
|
410
|
-
}
|
|
411
|
-
return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function findingOf(error: unknown, step: string): Finding {
|
|
415
|
-
// A step may throw anything, including an Error that fights being read: `instanceof` runs a
|
|
416
|
-
// Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
|
|
417
|
-
// gate's own report down with it — the one message that may never be lost.
|
|
418
|
-
const cause = renderThrowable(error);
|
|
419
|
-
return {
|
|
420
|
-
code: 'X_VERIFY_FAILED',
|
|
421
|
-
cause: `step "${step}" threw: ${cause}`,
|
|
422
|
-
fix: 'x verify --json',
|
|
423
|
-
docs: 'https://ultimate.dev/errors/X_VERIFY_FAILED',
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* One `MetaIssue` as the gate reports it. `at` is the route FILE and never the URL: every seo error
|
|
429
|
-
* already names the file in its cause, and `at` is what an agent opens.
|
|
430
|
-
*/
|
|
431
|
-
const seoFinding = (issue: MetaIssue): Finding => ({
|
|
432
|
-
code: issue.code,
|
|
433
|
-
cause: issue.cause,
|
|
434
|
-
fix: issue.fix,
|
|
435
|
-
docs: `https://ultimate.dev/errors/${issue.code}`,
|
|
436
|
-
at: issue.file,
|
|
437
|
-
});
|
|
16
|
+
// One import path for the gate, unchanged by the split: `index.ts`, `x build` and the MCP host all
|
|
17
|
+
// reach the list and the runner through this module, and a second path to either would be the
|
|
18
|
+
// ambiguity axiom 1 forbids.
|
|
19
|
+
export { VERIFY_STEPS } from './verify-checks';
|
|
20
|
+
export { runVerify } from './verify-run';
|
|
438
21
|
|
|
439
22
|
export const verifyCommand: CliCommand = {
|
|
440
23
|
spec: {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The specifiers every `bun build --compile` in this repo must refuse to resolve. One list, because
|
|
2
|
+
// the flag is repeated at each compile site — `binaryArgs`, `docker/Dockerfile`, the boot e2e — and
|
|
3
|
+
// three copies of a resolver allowlist is three chances for one of them to be the stale one.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@babel/preset-typescript` is reached from `@babel/core`'s `.cts`-config loader and from nowhere
|
|
7
|
+
* else. `config/files/module-types.js` `require`s it twice — once for the preset, once for its
|
|
8
|
+
* `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
|
|
9
|
+
* loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
|
|
10
|
+
* config file is ever loaded and neither line is reachable at run time. The bundler walks them
|
|
11
|
+
* anyway, and that is the whole failure: Bun 1.3 (what CI pins and what `docker/Dockerfile` builds
|
|
12
|
+
* on) refuses the build with `Could not resolve: "@babel/preset-typescript/package.json"`, while
|
|
13
|
+
* Bun 1.4 bundles the unresolvable `require` as a runtime throw — so one tree compiled on a laptop
|
|
14
|
+
* and did not in CI.
|
|
15
|
+
*
|
|
16
|
+
* Marking the dead specifier external rather than the two live ones: `serve.ts` calls
|
|
17
|
+
* `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
|
|
18
|
+
* binary that dies at start with `Cannot find module '@babel/core' from '/$bunfs/root/app'` —
|
|
19
|
+
* measured. And rather than installing `@babel/preset-typescript`: that is a real dependency, in
|
|
20
|
+
* the lockfile and in every published tarball's resolution graph, bought to make one unreachable
|
|
21
|
+
* line resolvable.
|
|
22
|
+
*
|
|
23
|
+
* A lazy `await import('@babel/core')` does not help and was measured too — Bun's bundler follows a
|
|
24
|
+
* literal dynamic specifier into `--compile`, so the graph still reaches the same require.
|
|
25
|
+
*/
|
|
26
|
+
export const COMPILE_EXTERNALS: readonly string[] = ['@babel/preset-typescript'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `--external <specifier>` per entry, flattened into the argv shape every compile site splices in.
|
|
30
|
+
* A function and not a frozen array so a caller cannot hold a reference it then mutates.
|
|
31
|
+
*/
|
|
32
|
+
export function externalArgs(): readonly string[] {
|
|
33
|
+
return COMPILE_EXTERNALS.flatMap((specifier) => ['--external', specifier]);
|
|
34
|
+
}
|