@ultimat3/cli 19.2.0 → 19.3.2
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 +135 -9
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +96 -25
- package/src/budgets.ts +17 -6
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +48 -46
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +25 -3
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +35 -9
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-files.ts +24 -2
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/scaffold-fixture.ts +17 -0
- package/src/serve.ts +40 -5
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/action.ts +47 -16
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +11 -8
- package/src/verify-floor.ts +59 -3
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/test-shards.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { execOutput } from './exec';
|
|
|
40
40
|
import { msg } from './messages';
|
|
41
41
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
42
42
|
import { quoteArg } from './shell-quote';
|
|
43
|
+
import { testPasses } from './test-passes';
|
|
43
44
|
import type { TestFile } from './test-select';
|
|
44
45
|
import type { TestType } from './verify-tests';
|
|
45
46
|
|
|
@@ -65,15 +66,26 @@ export function testArgs(input: {
|
|
|
65
66
|
readonly workers: number;
|
|
66
67
|
/** 0-based, matching `--worker`. Absent runs the whole selection across `workers` processes. */
|
|
67
68
|
readonly shard?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Everything after a bare `--`, handed to `bun test` verbatim and BEFORE the file list, which is
|
|
71
|
+
* where bun reads its flags. `ParsedArgs.passthrough` had no reader anywhere until 2026-09, so
|
|
72
|
+
* `x test unit -- --coverage --bail` parsed both flags, carried them through the command and
|
|
73
|
+
* dropped them on the floor — a run that reported exactly what a coverage run reports, with no
|
|
74
|
+
* coverage measured. `CommandSpec.passthrough` is what keeps the other commands from doing the
|
|
75
|
+
* same in silence: they refuse the `--` instead.
|
|
76
|
+
*/
|
|
77
|
+
readonly passthrough?: readonly string[];
|
|
68
78
|
}): readonly string[] {
|
|
69
79
|
const files = [...input.files].sort();
|
|
80
|
+
const extra = input.passthrough ?? [];
|
|
70
81
|
return input.shard === undefined
|
|
71
|
-
? ['bun', 'test', `--parallel=${String(input.workers)}`, ...files]
|
|
82
|
+
? ['bun', 'test', `--parallel=${String(input.workers)}`, ...extra, ...files]
|
|
72
83
|
: [
|
|
73
84
|
'bun',
|
|
74
85
|
'test',
|
|
75
86
|
'--isolate',
|
|
76
87
|
`--shard=${String(input.shard + 1)}/${String(input.workers)}`,
|
|
88
|
+
...extra,
|
|
77
89
|
...files,
|
|
78
90
|
];
|
|
79
91
|
}
|
|
@@ -102,6 +114,8 @@ export interface ReproduceOptions {
|
|
|
102
114
|
readonly affected?: AffectedSelection;
|
|
103
115
|
/** 0-based, when reproducing ONE shard. Absent reproduces the whole selection. */
|
|
104
116
|
readonly shard?: number;
|
|
117
|
+
/** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
|
|
118
|
+
readonly passthrough?: readonly string[];
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/**
|
|
@@ -124,6 +138,11 @@ export function reproduceFor(options: ReproduceOptions): string {
|
|
|
124
138
|
'--workers',
|
|
125
139
|
String(options.workers),
|
|
126
140
|
...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
|
|
141
|
+
// Last, and after a `--` of its own, because that is where the caller typed it and where the
|
|
142
|
+
// parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
|
|
143
|
+
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
144
|
+
? []
|
|
145
|
+
: ['--', ...options.passthrough.map(quoteArg)]),
|
|
127
146
|
].join(' ');
|
|
128
147
|
}
|
|
129
148
|
|
|
@@ -144,16 +163,28 @@ export interface RunShardsOptions {
|
|
|
144
163
|
readonly sample?: { readonly kept: number; readonly total: number };
|
|
145
164
|
/** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
|
|
146
165
|
readonly affected?: AffectedSelection;
|
|
166
|
+
/** Everything after the caller's `--`, forwarded to every pass and printed in the reproduce. */
|
|
167
|
+
readonly passthrough?: readonly string[];
|
|
147
168
|
}
|
|
148
169
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The reproduction's inputs for ONE pass: `workers` is that pass's real width, not the ask, and
|
|
172
|
+
* `type` is the pass's own when the split gave it one — `x test live --workers 1` reruns exactly
|
|
173
|
+
* the files that failed, where the whole invocation's flags would rerun the corpus around them.
|
|
174
|
+
*/
|
|
175
|
+
const planOf = (
|
|
176
|
+
options: RunShardsOptions,
|
|
177
|
+
pass: { readonly workers: number; readonly type?: TestType },
|
|
178
|
+
): ReproduceOptions => ({
|
|
179
|
+
workers: pass.workers,
|
|
152
180
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
153
|
-
...(
|
|
181
|
+
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
154
182
|
...(options.sample === undefined ? {} : { sample: options.sample.kept }),
|
|
155
183
|
...(options.affected === undefined ? {} : { affected: options.affected }),
|
|
156
184
|
...(options.only === undefined ? {} : { shard: options.only }),
|
|
185
|
+
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
186
|
+
? {}
|
|
187
|
+
: { passthrough: options.passthrough }),
|
|
157
188
|
});
|
|
158
189
|
|
|
159
190
|
/**
|
|
@@ -173,68 +204,111 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
173
204
|
});
|
|
174
205
|
|
|
175
206
|
/**
|
|
176
|
-
* ONE `bun test
|
|
177
|
-
*
|
|
207
|
+
* ONE `bun test` PER PASS, and one pass unless the selection mixes serial files with the rest —
|
|
208
|
+
* `test-passes.ts` decides that, and this spends it. Bun owns the pool inside a pass and hands
|
|
209
|
+
* each free worker the next file, so nothing here decides which file runs where; see this file's
|
|
210
|
+
* header for what that measured.
|
|
211
|
+
*
|
|
212
|
+
* Sequential, never `Promise.all`: the whole point of a serial pass is that nothing runs beside
|
|
213
|
+
* it. And every pass runs even after one fails — the caller asked for a suite, and a report that
|
|
214
|
+
* stops at the first red step hides the rest of the answer.
|
|
178
215
|
*
|
|
179
216
|
* `ULTIMATE_TEST_WORKER` is still set for a `--worker` rerun and only then: that run is one
|
|
180
217
|
* process, so naming its database is this file's to do. A `--parallel` run has N of them and Bun
|
|
181
218
|
* numbers each with `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
182
219
|
*/
|
|
183
220
|
export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
|
|
184
|
-
const files = options.files.map((file) => file.path);
|
|
185
|
-
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
186
221
|
const only = options.only;
|
|
222
|
+
const passes = testPasses({
|
|
223
|
+
files: options.files,
|
|
224
|
+
workers: options.workers,
|
|
225
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
226
|
+
...(only === undefined ? {} : { shard: only }),
|
|
227
|
+
});
|
|
187
228
|
const started = performance.now();
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
229
|
+
const steps: StepResult[] = [];
|
|
230
|
+
const spent: JsonValue[] = [];
|
|
231
|
+
let ok = true;
|
|
232
|
+
let exitCode = 0;
|
|
233
|
+
for (const pass of passes) {
|
|
234
|
+
const files = pass.files.map((file) => file.path);
|
|
235
|
+
const result = await options.runner(
|
|
236
|
+
testArgs({
|
|
237
|
+
files,
|
|
238
|
+
workers: pass.workers,
|
|
239
|
+
...(only === undefined ? {} : { shard: only }),
|
|
240
|
+
...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
|
|
241
|
+
}),
|
|
242
|
+
{
|
|
243
|
+
cwd: options.root,
|
|
244
|
+
...(only === undefined ? {} : { env: { ULTIMATE_TEST_WORKER: String(only) } }),
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
const plan = planOf(options, pass);
|
|
248
|
+
const label =
|
|
249
|
+
only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
|
|
250
|
+
steps.push({
|
|
251
|
+
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files`,
|
|
204
252
|
ok: result.ok,
|
|
205
253
|
durationMs: result.durationMs,
|
|
206
254
|
// `output.ts` documents this field as absent for a NON-test step, so omitting it here made
|
|
207
255
|
// `renderJson` describe the test step as one — recoverable only by parsing `name`.
|
|
208
|
-
workers,
|
|
256
|
+
workers: pass.workers,
|
|
209
257
|
findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
|
|
210
258
|
output: execOutput(result),
|
|
211
|
-
}
|
|
212
|
-
|
|
259
|
+
});
|
|
260
|
+
spent.push({
|
|
261
|
+
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
262
|
+
files: files.length,
|
|
263
|
+
workers: pass.workers,
|
|
264
|
+
ok: result.ok,
|
|
265
|
+
exitCode: result.code,
|
|
266
|
+
reproduce: reproduceFor(plan),
|
|
267
|
+
});
|
|
268
|
+
ok = ok && result.ok;
|
|
269
|
+
if (exitCode === 0) exitCode = result.code;
|
|
270
|
+
}
|
|
271
|
+
const durationMs = Math.round(performance.now() - started);
|
|
272
|
+
const fileCount = options.files.length;
|
|
273
|
+
// The width the RUN reached, which is the widest pass: a mixed selection whose serial half ran
|
|
274
|
+
// one at a time did not become a one-worker run, and reporting it as one would misname the
|
|
275
|
+
// reproduce a reader is handed.
|
|
276
|
+
const workers = Math.max(1, ...passes.map((pass) => pass.workers));
|
|
277
|
+
const plan = planOf(options, {
|
|
278
|
+
workers,
|
|
279
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
280
|
+
});
|
|
281
|
+
const type = options.type;
|
|
282
|
+
const typeParam = type === undefined ? {} : { type };
|
|
283
|
+
const sample = options.sample;
|
|
213
284
|
const data: JsonValue = {
|
|
214
285
|
...typeParam,
|
|
215
286
|
workers,
|
|
216
|
-
files:
|
|
287
|
+
files: fileCount,
|
|
217
288
|
durationMs,
|
|
218
289
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
219
290
|
...(sample === undefined ? {} : { sample: { kept: sample.kept, total: sample.total } }),
|
|
220
291
|
...(only === undefined ? {} : { shard: only }),
|
|
221
|
-
|
|
222
|
-
|
|
292
|
+
// Only when the split made more than one, so a single-pass run's JSON is byte-identical to
|
|
293
|
+
// what it has always been — and a mixed one can never be read as if it were a single run.
|
|
294
|
+
...(spent.length > 1 ? { passes: spent } : {}),
|
|
295
|
+
ok,
|
|
296
|
+
exitCode,
|
|
223
297
|
reproduce: reproduceFor(plan),
|
|
224
298
|
};
|
|
225
299
|
return {
|
|
226
|
-
ok
|
|
300
|
+
ok,
|
|
227
301
|
command: 'test',
|
|
228
|
-
summary:
|
|
302
|
+
summary: ok
|
|
229
303
|
? msg(type === undefined ? 'cli.test.pass' : 'cli.test.type.pass', {
|
|
230
304
|
...typeParam,
|
|
231
|
-
files:
|
|
305
|
+
files: fileCount,
|
|
232
306
|
workers,
|
|
233
307
|
ms: durationMs,
|
|
234
308
|
})
|
|
235
309
|
: msg(type === undefined ? 'cli.test.fail' : 'cli.test.type.fail', {
|
|
236
310
|
...typeParam,
|
|
237
|
-
failed:
|
|
311
|
+
failed: steps.filter((step) => !step.ok).length,
|
|
238
312
|
workers,
|
|
239
313
|
}),
|
|
240
314
|
steps,
|
|
@@ -242,6 +316,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
242
316
|
? {}
|
|
243
317
|
: { lines: [msg('cli.test.sampled', { ...sample, type: type ?? 'all' })] }),
|
|
244
318
|
data,
|
|
245
|
-
exitCode:
|
|
319
|
+
exitCode: ok ? 0 : 1,
|
|
246
320
|
};
|
|
247
321
|
}
|
package/src/verify-checks.ts
CHANGED
|
@@ -80,7 +80,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
80
80
|
{
|
|
81
81
|
name: 'boundaries',
|
|
82
82
|
summary: "surface, layer and package-tier imports, and the app's own guards",
|
|
83
|
-
// An app's `guards/` rides here rather than becoming
|
|
83
|
+
// An app's `guards/` rides here rather than becoming a step of its own, for the reason the
|
|
84
84
|
// seam already states: a host adds findings to a step, it can never add, remove, reorder or
|
|
85
85
|
// skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
|
|
86
86
|
// already carries "rules this repo makes about itself that the framework cannot know" (the
|
|
@@ -106,7 +106,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
106
106
|
name: 'package-shape',
|
|
107
107
|
summary: 'every package ships the same contract files',
|
|
108
108
|
applies: (ctx) => hasWorkspacePackages(ctx.root),
|
|
109
|
-
// The dependency rule rides here rather than becoming a
|
|
109
|
+
// The dependency rule rides here rather than becoming a step of its own because it is this
|
|
110
110
|
// step's own question — what does a workspace owe the repo it lives in? — asked of the
|
|
111
111
|
// manifest's `dependencies` instead of its `files`. It is deliberately NOT inside
|
|
112
112
|
// `checkPackageShape`: `scripts/release.ts --check` calls that one to ask whether the tree is
|
|
@@ -155,7 +155,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
155
155
|
// Source, not database: the gate runs in CI with nothing listening, and the database half is
|
|
156
156
|
// the post-migrate verification `runMigrations` performs where a connection is already open.
|
|
157
157
|
//
|
|
158
|
-
// The destructive rail rides here rather than becoming
|
|
158
|
+
// The destructive rail rides here rather than becoming a step of its own because it asks this
|
|
159
159
|
// step's own question — do the committed migrations still describe what the app is doing to its
|
|
160
160
|
// schema? — off the same directory, in the same pass, with no database either.
|
|
161
161
|
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
@@ -166,7 +166,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
166
166
|
// The third rail, and the one the other two cannot see: a hand-written statement is
|
|
167
167
|
// recorded by no snapshot and hashed by no source, so both halves above are green over SQL
|
|
168
168
|
// a squash silently drops. Same directory, same reader, no database — this step's own
|
|
169
|
-
// question, which is why it is not
|
|
169
|
+
// question, which is why it is not a step of its own.
|
|
170
170
|
...(await checkUngeneratableMigrations(ctx.root)),
|
|
171
171
|
]),
|
|
172
172
|
},
|
|
@@ -191,7 +191,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
191
191
|
name: 'budgets',
|
|
192
192
|
summary:
|
|
193
193
|
'per-route JS bytes and LCP, the global style layer every document carries, and the routes that boot nothing to receive their live rows',
|
|
194
|
-
// The global-style assertion rides here rather than becoming
|
|
194
|
+
// The global-style assertion rides here rather than becoming a step of its own, because this
|
|
195
195
|
// step already asks the one question it asks: what does the document this build emits actually
|
|
196
196
|
// contain? It is also the same app load — `appManifest` fills render's stylesheet registry on
|
|
197
197
|
// its way through — so a separate step would pay for a second one to answer half a question.
|
|
@@ -295,7 +295,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
295
295
|
// once, and says so by finding nothing — but `AGENTS.md` is required of every repo the gate
|
|
296
296
|
// runs in, so the step always has a question to answer and must never report as skipped.
|
|
297
297
|
//
|
|
298
|
-
// `.env.example` joins this step rather than becoming
|
|
298
|
+
// `.env.example` joins this step rather than becoming one of its own: the question is the same
|
|
299
299
|
// one — "does a committed, generated file still describe the code?" — and the step list is the
|
|
300
300
|
// definition of shippable, so it grows only when a genuinely new question needs asking.
|
|
301
301
|
//
|
|
@@ -304,12 +304,15 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
304
304
|
// vanish, so a typo in the floor covers nothing — which is the false green the floor exists to
|
|
305
305
|
// close, and it is only visible if something reads the file for its own sake.
|
|
306
306
|
async run(ctx) {
|
|
307
|
-
|
|
307
|
+
// The floor is read FIRST: it is where a repository declares its own `AGENTS.md` budget,
|
|
308
|
+
// and reading it after the check would enforce the default on a repo that raised it.
|
|
309
|
+
const floor = await readVerifyFloor(ctx.root);
|
|
310
|
+
const agents = await checkAgentsMd(ctx.root, floor?.agentsMdMaxBytes);
|
|
308
311
|
const findings = [
|
|
309
312
|
...manifestMissingFindings(ctx.root),
|
|
310
313
|
...(await driftFindings(ctx.root)),
|
|
311
314
|
...(await envExampleFindings(ctx.root)),
|
|
312
|
-
...floorProblemFindings(
|
|
315
|
+
...floorProblemFindings(floor),
|
|
313
316
|
...agents.findings,
|
|
314
317
|
...(await hostFindings(ctx, 'manifest')),
|
|
315
318
|
];
|
package/src/verify-floor.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
11
|
+
import { AGENTS_MD_MAX_BYTES } from '@ultimat3/manifest';
|
|
11
12
|
import type { Finding } from './output';
|
|
12
13
|
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
13
14
|
|
|
@@ -17,10 +18,45 @@ export const VERIFY_FLOOR_FILE = 'x.verify.json';
|
|
|
17
18
|
export interface VerifyFloor {
|
|
18
19
|
/** Declared step names this run may not report as skipped. */
|
|
19
20
|
readonly steps: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* This repository's `AGENTS.md` budget, in bytes, when it declares one. `@ultimat3/manifest`
|
|
23
|
+
* has always taken a `maxBytes` and the gate never passed one, so the 12kB default was the only
|
|
24
|
+
* budget an app could have — and an app whose conventions genuinely need more had no move left
|
|
25
|
+
* but to delete a rule to make room, which is the opposite of what the budget is for.
|
|
26
|
+
*
|
|
27
|
+
* It is here rather than in `x.config.ts` because this is the file that configures the GATE,
|
|
28
|
+
* and it is read by the very step that enforces the budget. Raising it is a commit a reviewer
|
|
29
|
+
* sees, which is the whole safeguard: the number is small, visible, and argued for in one place.
|
|
30
|
+
*/
|
|
31
|
+
readonly agentsMdMaxBytes?: number;
|
|
20
32
|
/** Why part of the file is not a floor. The `manifest` step reports these; nothing swallows them. */
|
|
21
33
|
readonly problems: readonly string[];
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
/** The floor's budget key. Named once: the problem quotes it and the fix repairs it. */
|
|
37
|
+
export const BUDGET_FIELD = 'agentsMdMaxBytes';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `agentsMdMaxBytes`, or a reason it is not one. A budget that is not a positive whole number is
|
|
41
|
+
* the caller's bug and must not silently fall back to the default: a floor file that says
|
|
42
|
+
* `"agentsMdMaxBytes": "16kb"` and is quietly ignored is a repository that believes it raised a
|
|
43
|
+
* budget it did not, and finds out when the gate goes red on a commit that changed nothing.
|
|
44
|
+
*/
|
|
45
|
+
function readBudget(payload: Record<string, unknown> | undefined): {
|
|
46
|
+
budget?: number;
|
|
47
|
+
problems: readonly string[];
|
|
48
|
+
} {
|
|
49
|
+
const raw = payload?.[BUDGET_FIELD];
|
|
50
|
+
if (raw === undefined) return { problems: [] };
|
|
51
|
+
if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw <= 0)
|
|
52
|
+
return {
|
|
53
|
+
problems: [
|
|
54
|
+
`"${BUDGET_FIELD}" is ${JSON.stringify(raw)}, which is not a positive whole number of bytes`,
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
return { budget: raw, problems: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
24
60
|
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
25
61
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
62
|
? (value as Record<string, unknown>)
|
|
@@ -48,19 +84,27 @@ export function parseVerifyFloor(
|
|
|
48
84
|
// was meant to make the path safe (`metrics-endpoint.ts` states the same rule over `stringField`).
|
|
49
85
|
return { steps: [], problems: [`it does not parse as JSON (${renderThrowable(error)})`] };
|
|
50
86
|
}
|
|
51
|
-
const
|
|
87
|
+
const record = asRecord(payload);
|
|
88
|
+
const budget = readBudget(record);
|
|
89
|
+
const steps = record?.['steps'];
|
|
52
90
|
if (!Array.isArray(steps)) {
|
|
53
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
steps: [],
|
|
93
|
+
...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
|
|
94
|
+
problems: ['it has no "steps" array of step names', ...budget.problems],
|
|
95
|
+
};
|
|
54
96
|
}
|
|
55
97
|
const named = steps.filter((step): step is string => typeof step === 'string');
|
|
56
98
|
const unknown = named.filter((step) => !declared.includes(step));
|
|
57
99
|
return {
|
|
58
100
|
steps: named.filter((step) => declared.includes(step)),
|
|
101
|
+
...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
|
|
59
102
|
problems: [
|
|
60
103
|
...(named.length === steps.length ? [] : ['"steps" holds an entry that is not a string']),
|
|
61
104
|
...(unknown.length === 0
|
|
62
105
|
? []
|
|
63
106
|
: [`"steps" names ${unknown.join(', ')}, which x verify does not run`]),
|
|
107
|
+
...budget.problems,
|
|
64
108
|
],
|
|
65
109
|
};
|
|
66
110
|
}
|
|
@@ -129,7 +173,19 @@ export const floorProblemFindings = (floor: VerifyFloor | undefined): readonly F
|
|
|
129
173
|
(floor?.problems ?? []).map((problem) => ({
|
|
130
174
|
code: 'X_CONFIG_INVALID',
|
|
131
175
|
cause: `${VERIFY_FLOOR_FILE} is not a suite floor: ${problem}`,
|
|
132
|
-
fix:
|
|
176
|
+
fix: fixFor(problem),
|
|
133
177
|
docs: ERROR_DOCS_URL,
|
|
134
178
|
at: VERIFY_FLOOR_FILE,
|
|
135
179
|
}));
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The edit that repairs THIS problem, not the file in general. The steps-shaped fix is useless
|
|
183
|
+
* against a bad budget — it prints a `{"steps":[…]}` example with no `agentsMdMaxBytes` in it, so
|
|
184
|
+
* an author who followed it verbatim would still have the value that failed. A finding whose fix
|
|
185
|
+
* does not fix it is the failure `packages/cli/CLAUDE.md` names, and the budget is the first
|
|
186
|
+
* problem this file can report that is not about `steps` at all.
|
|
187
|
+
*/
|
|
188
|
+
const fixFor = (problem: string): string =>
|
|
189
|
+
problem.includes(`"${BUDGET_FIELD}"`)
|
|
190
|
+
? `x verify --json # then set "${BUDGET_FIELD}" in ${VERIFY_FLOOR_FILE} to a positive whole number of bytes, or drop the key for the ${AGENTS_MD_MAX_BYTES}B default`
|
|
191
|
+
: `x verify --json # then write ${VERIFY_FLOOR_FILE} as {"steps":["unit","contract"]}, naming only steps it ran`;
|
package/src/verify-step.ts
CHANGED
|
@@ -29,19 +29,19 @@ export const VERIFY_STEP_NAMES = [
|
|
|
29
29
|
'drift',
|
|
30
30
|
'contract-diff',
|
|
31
31
|
'budgets',
|
|
32
|
-
//
|
|
32
|
+
// A deliberate widening of a closed list rather than a `HostCheck`: an SEO gate
|
|
33
33
|
// is a MECHANISM every app with a `site/` surface wants (axiom 8), not a rule one host repo
|
|
34
34
|
// enforces — and `verifyCommand.run` passes no host checks at all, so the app path could not
|
|
35
35
|
// have carried it. It runs beside `budgets` because both read the app the same load produced.
|
|
36
36
|
'seo',
|
|
37
|
-
//
|
|
38
|
-
//
|
|
37
|
+
// Here by the same test the SEO step above passed, and not a rider for the same reason:
|
|
38
|
+
// `boundaries` asks whether an import was LEGAL and this asks whether a declaration
|
|
39
39
|
// REACHED the running app, which is a different question with a different fix (axiom 4). It
|
|
40
40
|
// costs no second app load — `budgets` already imported every module, and this reads the
|
|
41
41
|
// registries that load filled. Until it existed, an app could ship every user-facing string as
|
|
42
42
|
// `⟦key⟧` with `x verify` green, because nothing in the gate ever asked (issue #249).
|
|
43
43
|
'i18n',
|
|
44
|
-
//
|
|
44
|
+
// Here by the same test `seo` and `i18n` each passed: a rider must ask the SAME question
|
|
45
45
|
// off the same data, and "was this import legal?" is not "does the permission this app grants
|
|
46
46
|
// and requires exist?". Reported under `budgets` it would hand the reader a byte budget for an
|
|
47
47
|
// authz defect (axiom 4). Until it existed, `x new` shipped an app that answered HTTP 500 with
|
package/src/verify-tests.ts
CHANGED
|
@@ -44,7 +44,9 @@ const TYPED_SUFFIXES = '{contract,live,job,e2e,eval}';
|
|
|
44
44
|
* four sweeps. `type` is a closed union here and cannot be `'constructor'` today, which is
|
|
45
45
|
* exactly the argument every one of those thirteen had before it stopped being true.
|
|
46
46
|
*
|
|
47
|
-
* Same repair, and the same reason, as `packages/i18n/src/catalog.ts`.
|
|
47
|
+
* Same repair, and the same reason, as `packages/i18n/src/catalog.ts`. The read itself is guarded
|
|
48
|
+
* too (`summaryOf`), because a construction three screens above a read is not something a static
|
|
49
|
+
* rule should have to reason about.
|
|
48
50
|
*/
|
|
49
51
|
const SUMMARIES: Readonly<Record<TypedTest, string>> = Object.assign(
|
|
50
52
|
Object.create(null) as Record<TypedTest, string>,
|
|
@@ -255,6 +257,16 @@ const evalStep: VerifyStep = {
|
|
|
255
257
|
},
|
|
256
258
|
};
|
|
257
259
|
|
|
260
|
+
/**
|
|
261
|
+
* The one computed read of `SUMMARIES`, guarded. The table is already prototype-free
|
|
262
|
+
* (`Object.create(null)`), which is what makes the READ safe — and `scripts/proto-index.ts`
|
|
263
|
+
* recognises a guard on the read itself, not a construction three screens above it. So the guard
|
|
264
|
+
* is here: a rule that has to reason about how a table was built is one tightening away from
|
|
265
|
+
* reporting this line, and a pin is a rule with a hole in it.
|
|
266
|
+
*/
|
|
267
|
+
const summaryOf = (type: TypedTest): string =>
|
|
268
|
+
Object.hasOwn(SUMMARIES, type) ? SUMMARIES[type] : `${type} tests`;
|
|
269
|
+
|
|
258
270
|
const stepFor = (type: TestType): VerifyStep => {
|
|
259
271
|
if (type === 'unit') {
|
|
260
272
|
return {
|
|
@@ -266,7 +278,7 @@ const stepFor = (type: TestType): VerifyStep => {
|
|
|
266
278
|
if (type === 'eval') return evalStep;
|
|
267
279
|
return {
|
|
268
280
|
name: type,
|
|
269
|
-
summary:
|
|
281
|
+
summary: summaryOf(type),
|
|
270
282
|
applies: async (ctx) => (await filesFor(ctx.root, type)).length > 0,
|
|
271
283
|
run: (ctx) => runType(ctx, type),
|
|
272
284
|
};
|