@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/ts-scan.ts
CHANGED
|
@@ -118,11 +118,25 @@ export const stripComments = (text: string): string => blankRegions(text, false)
|
|
|
118
118
|
/** Comments and string contents gone, delimiters kept — what a scan for code structure reads. */
|
|
119
119
|
export const maskLiterals = (text: string): string => blankRegions(text, true);
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Line numbers for one text, in one pass. Counting newlines per lookup is O(index), which a scan
|
|
123
|
+
* asking for a line per literal pays once per literal — measured at ~15s over the framework's own
|
|
124
|
+
* package tree, against ~1s for the same walk with this. One offset table, then a binary search.
|
|
125
|
+
*/
|
|
126
|
+
function lineIndex(text: string): (index: number) => number {
|
|
127
|
+
const newlines: number[] = [];
|
|
128
|
+
for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') newlines.push(i);
|
|
129
|
+
return (index) => {
|
|
130
|
+
let low = 0;
|
|
131
|
+
let high = newlines.length;
|
|
132
|
+
while (low < high) {
|
|
133
|
+
const mid = (low + high) >> 1;
|
|
134
|
+
if ((newlines[mid] as number) < index) low = mid + 1;
|
|
135
|
+
else high = mid;
|
|
136
|
+
}
|
|
137
|
+
return low + 1;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
126
140
|
|
|
127
141
|
/**
|
|
128
142
|
* Every string literal in the value expression starting at `from`, at the expression's own bracket
|
|
@@ -130,7 +144,12 @@ const lineOf = (text: string, index: number): number => {
|
|
|
130
144
|
* instead of silently skipped; the depth rule is what keeps `command.join(' ')`'s separator and
|
|
131
145
|
* `table['key']`'s key out — an argument is not a fix.
|
|
132
146
|
*/
|
|
133
|
-
function valueLiterals(
|
|
147
|
+
function valueLiterals(
|
|
148
|
+
masked: string,
|
|
149
|
+
source: string,
|
|
150
|
+
from: number,
|
|
151
|
+
lineAt: (index: number) => number,
|
|
152
|
+
): readonly FixSite[] {
|
|
134
153
|
const found: { value: string; index: number }[] = [];
|
|
135
154
|
let depth = 0;
|
|
136
155
|
for (let i = from; i < masked.length; i += 1) {
|
|
@@ -147,7 +166,7 @@ function valueLiterals(masked: string, source: string, from: number): readonly F
|
|
|
147
166
|
}
|
|
148
167
|
return found.map((literal) => ({
|
|
149
168
|
at: '',
|
|
150
|
-
line:
|
|
169
|
+
line: lineAt(literal.index),
|
|
151
170
|
fix: literal.value,
|
|
152
171
|
}));
|
|
153
172
|
}
|
|
@@ -155,25 +174,180 @@ function valueLiterals(masked: string, source: string, from: number): readonly F
|
|
|
155
174
|
/** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
|
|
156
175
|
const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
|
|
157
176
|
|
|
177
|
+
/** Text between the bracket at `open` and its match, or `undefined` when it never closes. */
|
|
178
|
+
function bracketSpan(masked: string, open: number): string | undefined {
|
|
179
|
+
let depth = 0;
|
|
180
|
+
for (let i = open; i < masked.length; i += 1) {
|
|
181
|
+
const ch = masked[i] as string;
|
|
182
|
+
// Only `()[]{}`. An angle bracket is a generic in a parameter list and the tail of `=>` in the
|
|
183
|
+
// very same list, so counting it makes `(fn: () => void)` end the span in the wrong place.
|
|
184
|
+
if (OPENERS.has(ch)) depth += 1;
|
|
185
|
+
else if (CLOSERS.has(ch)) {
|
|
186
|
+
depth -= 1;
|
|
187
|
+
if (depth === 0) return masked.slice(open + 1, i);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Split at depth-0 commas. Safe on masked text, where a comma inside a literal is already gone. */
|
|
194
|
+
function topLevelParts(text: string): readonly string[] {
|
|
195
|
+
const parts: string[] = [];
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let start = 0;
|
|
198
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
199
|
+
const ch = text[i] as string;
|
|
200
|
+
if (OPENERS.has(ch)) depth += 1;
|
|
201
|
+
else if (CLOSERS.has(ch)) depth -= 1;
|
|
202
|
+
else if (ch === ',' && depth === 0) {
|
|
203
|
+
parts.push(text.slice(start, i));
|
|
204
|
+
start = i + 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
parts.push(text.slice(start));
|
|
208
|
+
return parts;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A local function that builds an error and takes its fix positionally, and where in its list. */
|
|
212
|
+
interface FixHelper {
|
|
213
|
+
readonly name: string;
|
|
214
|
+
readonly index: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const HELPER_DECL =
|
|
218
|
+
/(?<![.\w$])(?:function\s+([A-Za-z_$][\w$]*)\s*\(|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:async\s+)?\()/g;
|
|
219
|
+
|
|
220
|
+
const FIX_PARAM = /^\s*fix\s*:\s*string\s*$/;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What separates a helper that BUILDS a fix from one that CONSUMES one. `citedCommandProblem(fix:
|
|
224
|
+
* string, …)` in `fix-command.ts` takes a fix in order to judge it, and reading its call sites as
|
|
225
|
+
* declarations would report findings about strings that are already findings. A builder names a
|
|
226
|
+
* `code` or constructs an `…Error`; a consumer does neither.
|
|
227
|
+
*/
|
|
228
|
+
const BUILDS_ERROR = /(?<![.\w$])code\s*[:=]|new\s+[A-Za-z_$][\w$]*Error\s*\(/;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The body of the declaration whose parameter list ends at `after`, and never a `{` belonging to
|
|
232
|
+
* something below it. An unbounded `indexOf('{')` reads the next object literal in the FILE when
|
|
233
|
+
* the body is a concise expression, so `const label = (fix: string) => fix.trim();` followed
|
|
234
|
+
* anywhere by a `{ code: … }` was read as an error builder and every `label(…)` call handed the
|
|
235
|
+
* gate a string to judge as a fix — a false gate failure over innocent source.
|
|
236
|
+
*
|
|
237
|
+
* The scan therefore ends at the `;` that ends the declaration, or at a bracket closing a scope
|
|
238
|
+
* this declaration is inside. Both directions of that bound answer `''`, which classifies the
|
|
239
|
+
* helper as a non-builder: a missed fix line costs one unchecked citation, a wrongly claimed one
|
|
240
|
+
* costs a build. A `{` inside a return-type annotation (`(): { ok: boolean } => …`) is read as the
|
|
241
|
+
* body and answers `''` for the same reason.
|
|
242
|
+
*/
|
|
243
|
+
function bodyOf(masked: string, after: number): string {
|
|
244
|
+
for (let i = after; i < masked.length; i += 1) {
|
|
245
|
+
const ch = masked[i] as string;
|
|
246
|
+
if (ch === '{') return bracketSpan(masked, i) ?? '';
|
|
247
|
+
if (ch === ';' || CLOSERS.has(ch)) break;
|
|
248
|
+
}
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fixHelpers(masked: string): readonly FixHelper[] {
|
|
253
|
+
const helpers: FixHelper[] = [];
|
|
254
|
+
for (const declaration of masked.matchAll(HELPER_DECL)) {
|
|
255
|
+
const name = declaration[1] ?? declaration[2];
|
|
256
|
+
const open = declaration.index + declaration[0].length - 1;
|
|
257
|
+
if (name === undefined || masked[open] !== '(') continue;
|
|
258
|
+
const params = bracketSpan(masked, open);
|
|
259
|
+
// A rest parameter makes the position of everything after it unknowable, and a destructured
|
|
260
|
+
// one has no position at all — its `fix:` key at the CALL site is already read by `FIX_KEY`.
|
|
261
|
+
if (params === undefined || params.includes('...')) continue;
|
|
262
|
+
const parts = topLevelParts(params);
|
|
263
|
+
if (parts.some((part) => /^\s*[[{]/.test(part))) continue;
|
|
264
|
+
const index = parts.findIndex((part) => FIX_PARAM.test(part));
|
|
265
|
+
if (index === -1) continue;
|
|
266
|
+
if (!BUILDS_ERROR.test(bodyOf(masked, open + params.length + 2))) continue;
|
|
267
|
+
helpers.push({ name, index });
|
|
268
|
+
}
|
|
269
|
+
return helpers;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The argument in that position at every call to that helper IN THIS FILE.
|
|
274
|
+
*
|
|
275
|
+
* Same file, deliberately: resolving `dbNotImplemented` imported from `@ultimat3/db` would mean a
|
|
276
|
+
* cross-file symbol table, and a scanner that guessed at which import a name came from would read
|
|
277
|
+
* an unrelated function's argument as a fix. The gap that leaves is named in `CLAUDE.md`.
|
|
278
|
+
*/
|
|
279
|
+
function helperFixSites(
|
|
280
|
+
masked: string,
|
|
281
|
+
source: string,
|
|
282
|
+
at: string,
|
|
283
|
+
helper: FixHelper,
|
|
284
|
+
lineAt: (index: number) => number,
|
|
285
|
+
): readonly FixSite[] {
|
|
286
|
+
const sites: FixSite[] = [];
|
|
287
|
+
// The lookbehind is `FIX_KEY`'s: `reporter.rejected(…)` is some other object's method.
|
|
288
|
+
const call = new RegExp(`(?<![.\\w$])${helper.name}\\s*\\(`, 'g');
|
|
289
|
+
for (const match of masked.matchAll(call)) {
|
|
290
|
+
const open = match.index + match[0].length - 1;
|
|
291
|
+
const args = bracketSpan(masked, open);
|
|
292
|
+
if (args === undefined) continue;
|
|
293
|
+
const parts = topLevelParts(args);
|
|
294
|
+
const argument = parts[helper.index];
|
|
295
|
+
if (argument === undefined) continue;
|
|
296
|
+
// Stricter than the `fix:` path, and deliberately: the whole argument must BE one literal.
|
|
297
|
+
// `valueLiterals` alone reads `prefix + 'x doctor'` as one literal, because the identifier
|
|
298
|
+
// half contributes none — and publishing half a fix as the whole one is the failure
|
|
299
|
+
// `soleLiteral` already names. A key at least declares that what follows is the value.
|
|
300
|
+
const quote = argument.trim()[0];
|
|
301
|
+
if (quote === undefined || !QUOTES.has(quote)) continue;
|
|
302
|
+
const literal = argument.indexOf(quote);
|
|
303
|
+
if (argument.slice(endOfLiteral(argument, literal)).trim() !== '') continue;
|
|
304
|
+
const from = parts.slice(0, helper.index).reduce((n, part) => n + part.length + 1, open + 1);
|
|
305
|
+
const literals = valueLiterals(masked, source, from, lineAt);
|
|
306
|
+
if (literals.length === 1) sites.push({ ...(literals[0] as FixSite), at });
|
|
307
|
+
}
|
|
308
|
+
return sites;
|
|
309
|
+
}
|
|
310
|
+
|
|
158
311
|
/**
|
|
159
312
|
* Every string a `fix:` can evaluate to. Searched over the masked source, so a `fix:` written
|
|
160
313
|
* inside a doc comment or interpolated into a message is not mistaken for a declaration. A `fix`
|
|
161
314
|
* computed at runtime — a bare identifier, a parameter, a table lookup with no literal fallback —
|
|
162
315
|
* has nothing to read and is beyond a static scan; the gate says so rather than guessing.
|
|
316
|
+
*
|
|
317
|
+
* Two shapes, because a fix does not always arrive under a key. `@ultimat3/mcp`'s `readonly-sql.ts`
|
|
318
|
+
* hands every one of its fixes positionally to a local `rejected(cause, fix)` helper, so the key
|
|
319
|
+
* rule alone returned `[]` for the whole file — 20 non-test files in that package and the scanner
|
|
320
|
+
* saw fixes in three — and two stale `x db branch <name>` lines shipped through the hole.
|
|
163
321
|
*/
|
|
164
322
|
export function scanFixes(source: string, at: string): readonly FixSite[] {
|
|
165
323
|
const masked = maskLiterals(source);
|
|
324
|
+
const lineAt = lineIndex(masked);
|
|
166
325
|
const sites: FixSite[] = [];
|
|
167
326
|
for (const key of masked.matchAll(FIX_KEY)) {
|
|
168
327
|
const start = key.index + key[0].length;
|
|
169
|
-
for (const literal of valueLiterals(masked, source, start))
|
|
328
|
+
for (const literal of valueLiterals(masked, source, start, lineAt)) {
|
|
329
|
+
sites.push({ ...literal, at });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const helper of fixHelpers(masked)) {
|
|
333
|
+
sites.push(...helperFixSites(masked, source, at, helper, lineAt));
|
|
170
334
|
}
|
|
171
335
|
return sites;
|
|
172
336
|
}
|
|
173
337
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
338
|
+
const CODE_TABLE = /\bexport const [A-Z][A-Z0-9_]*_ERROR_(?:CODES|TITLES)\b/;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Whether a file IS a package's code registry — asked of its contents, not of its name.
|
|
342
|
+
*
|
|
343
|
+
* A filename test (`errors.ts` or `error-codes.ts`) held only while one file per package did both
|
|
344
|
+
* jobs. The moment `@ultimat3/cli`'s split under the 500-line ceiling — table into `error-codes.ts`,
|
|
345
|
+
* classes into `errors.ts` — the classes file was still *named* like a registry, so every code it
|
|
346
|
+
* throws outranked the package that actually owns it and `X_NOT_IMPLEMENTED` moved from `core` to
|
|
347
|
+
* `cli` in the manifest. The table is the thing; `export const <PKG>_ERROR_{CODES,TITLES}` is the
|
|
348
|
+
* one shape every package declares it in, and `x verify`'s `errors` step is what keeps that true.
|
|
349
|
+
*/
|
|
350
|
+
export const isCodeRegistry = (source: string): boolean => CODE_TABLE.test(source);
|
|
177
351
|
|
|
178
352
|
const CODE_AT_KEY = /\bcode\s*[:=]\s*(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
179
353
|
const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
@@ -187,18 +361,102 @@ const CODE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
|
|
|
187
361
|
*/
|
|
188
362
|
export function scanCodes(source: string, at: string): readonly CodeSite[] {
|
|
189
363
|
const text = stripComments(source);
|
|
364
|
+
const lineAt = lineIndex(text);
|
|
190
365
|
const sites = new Map<string, CodeSite>();
|
|
191
366
|
const add = (code: string, index: number): void => {
|
|
192
|
-
if (!sites.has(code)) sites.set(code, { at, line:
|
|
367
|
+
if (!sites.has(code)) sites.set(code, { at, line: lineAt(index), code });
|
|
193
368
|
};
|
|
194
369
|
for (const match of text.matchAll(CODE_AT_KEY)) add(match[2] as string, match.index);
|
|
195
|
-
if (isCodeRegistry(
|
|
370
|
+
if (isCodeRegistry(text)) {
|
|
196
371
|
for (const match of text.matchAll(CODE_LITERAL)) add(match[2] as string, match.index);
|
|
197
372
|
for (const match of text.matchAll(CODE_KEY)) add(match[1] as string, match.index);
|
|
198
373
|
}
|
|
199
374
|
return [...sites.values()];
|
|
200
375
|
}
|
|
201
376
|
|
|
377
|
+
export interface CodeFixSite extends CodeSite {
|
|
378
|
+
/**
|
|
379
|
+
* The fix literal exactly as written, `${…}` included. Absent when the throw site builds its
|
|
380
|
+
* fix out of something this cannot read — a helper call, a parameter, a ternary with two
|
|
381
|
+
* branches — because a fix the scan has to guess at is one it must not report.
|
|
382
|
+
*/
|
|
383
|
+
readonly fix?: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `code:` / `code =`, or `fix:`. `fix =` is deliberately not a declaration — `scanFixes` agrees. */
|
|
387
|
+
const CODE_OR_FIX_KEY = /(?<![.\w$])(?:(code)\s*[:=]|(fix)\s*:)\s*/g;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The single literal a key's value evaluates to, or `undefined` when it evaluates to none or to
|
|
391
|
+
* more than one. Two is as unreadable as zero here: `cond ? 'a' : 'b'` and `'a' + 'b'` need a
|
|
392
|
+
* parser to tell apart, and picking a branch would publish half a fix as the whole one.
|
|
393
|
+
*/
|
|
394
|
+
function soleLiteral(
|
|
395
|
+
masked: string,
|
|
396
|
+
source: string,
|
|
397
|
+
from: number,
|
|
398
|
+
lineAt: (index: number) => number,
|
|
399
|
+
): FixSite | undefined {
|
|
400
|
+
const found = valueLiterals(masked, source, from, lineAt);
|
|
401
|
+
return found.length === 1 ? found[0] : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Every `X_*` code paired with the `fix:` written beside it — in the SAME object literal, which is
|
|
406
|
+
* the whole rule. `new UltimateError({ code, cause, fix })` is the one shape this framework raises
|
|
407
|
+
* an error in, so adjacency decides the pair without a parser and without asking which of a file's
|
|
408
|
+
* fixes belongs to which of its codes. `X_ERROR_FIX_INVALID` already proves every one of these is
|
|
409
|
+
* runnable, so a reader that projects them inherits that proof instead of restating it (axiom 2).
|
|
410
|
+
*
|
|
411
|
+
* A site is reported with no `fix` rather than dropped: "this code is raised here and the fix is
|
|
412
|
+
* computed" is a different, and more useful, answer than "this code does not exist".
|
|
413
|
+
*/
|
|
414
|
+
export function scanCodeFixSites(source: string, at: string): readonly CodeFixSite[] {
|
|
415
|
+
const masked = maskLiterals(source);
|
|
416
|
+
const lineAt = lineIndex(masked);
|
|
417
|
+
const keys = new Map<number, { readonly kind: 'code' | 'fix'; readonly from: number }>();
|
|
418
|
+
for (const key of masked.matchAll(CODE_OR_FIX_KEY)) {
|
|
419
|
+
keys.set(key.index, {
|
|
420
|
+
kind: key[1] === undefined ? 'fix' : 'code',
|
|
421
|
+
from: key.index + key[0].length,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const codes = new Map<number, CodeSite>();
|
|
425
|
+
const fixes = new Map<number, string>();
|
|
426
|
+
const stack: number[] = [];
|
|
427
|
+
for (let i = 0; i < masked.length; i += 1) {
|
|
428
|
+
const ch = masked[i] as string;
|
|
429
|
+
if (QUOTES.has(ch)) {
|
|
430
|
+
i = endOfLiteral(masked, i) - 1;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (OPENERS.has(ch)) {
|
|
434
|
+
stack.push(i);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (CLOSERS.has(ch)) {
|
|
438
|
+
stack.pop();
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const key = keys.get(i);
|
|
442
|
+
// Scope `-1` is the file body: a top-level `const code = 'X_A'` and an unrelated `fix:` far
|
|
443
|
+
// below it are not one declaration, and pairing them would invent an error nobody throws.
|
|
444
|
+
const scope = stack.at(-1);
|
|
445
|
+
if (key === undefined || scope === undefined) continue;
|
|
446
|
+
const literal = soleLiteral(masked, source, key.from, lineAt);
|
|
447
|
+
if (literal === undefined) continue;
|
|
448
|
+
if (key.kind === 'fix') {
|
|
449
|
+
if (!fixes.has(scope)) fixes.set(scope, literal.fix);
|
|
450
|
+
} else if (/^X_[A-Z0-9_]+$/.test(literal.fix) && !codes.has(scope)) {
|
|
451
|
+
codes.set(scope, { at, line: literal.line, code: literal.fix });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return [...codes].map(([scope, site]) => {
|
|
455
|
+
const fix = fixes.get(scope);
|
|
456
|
+
return fix === undefined ? site : { ...site, fix };
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
202
460
|
const BORROWED_LIST = /BORROWED_ERROR_CODES[^=]*=[^[]*\[([^\]]*)\]/g;
|
|
203
461
|
|
|
204
462
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// One rule: every published workspace is in the root build graph. `bun run typecheck` is `tsc -b`,
|
|
2
|
+
// which compiles referenced projects and nothing else — so a package no reference names is a
|
|
3
|
+
// package the gate's `typecheck` step reports green over without having read a line of it.
|
|
4
|
+
|
|
5
|
+
// Bun ships no equivalent: `join` builds the host-separator path from the scan root to the config.
|
|
6
|
+
// Nothing else here is `node:` — the read is `Bun.file`, and its rejection is also the answer for a
|
|
7
|
+
// root that has no `tsconfig.json` at all, so an `existsSync` ahead of it was a second question
|
|
8
|
+
// with one answer.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { docsFor } from './error-codes';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
|
|
13
|
+
const ROOT_TSCONFIG = 'tsconfig.json';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `./packages/cli`, `packages/cli/` and `packages/cli` are one project; the JSON allows all three.
|
|
17
|
+
* Exported because "is this project already referenced?" is asked in three places — here,
|
|
18
|
+
* `scripts/new-package.ts` when it adds an entry, and `scripts/reference-app-gate.ts` when it
|
|
19
|
+
* checks an app — and a spelling one of them treats as a match and another does not is how the
|
|
20
|
+
* scaffolder appends a duplicate entry the check already considered present.
|
|
21
|
+
*/
|
|
22
|
+
export const normalizeReferencePath = (path: string): string =>
|
|
23
|
+
path.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `undefined` means "this root does not use project references" — a different repo shape, not an
|
|
27
|
+
* empty graph. A scaffolded app is that shape (`extends` + `include`, no references at all), and
|
|
28
|
+
* telling its author to add an entry to a list that does not exist is a fix that makes the build
|
|
29
|
+
* worse. A tsconfig that will not parse is `typecheck`'s to report, with tsc's own message.
|
|
30
|
+
*/
|
|
31
|
+
async function referencedPaths(root: string): Promise<ReadonlySet<string> | undefined> {
|
|
32
|
+
const payload: unknown = await Bun.file(join(root, ROOT_TSCONFIG))
|
|
33
|
+
.json()
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
const references =
|
|
36
|
+
typeof payload === 'object' && payload !== null
|
|
37
|
+
? (payload as { references?: unknown }).references
|
|
38
|
+
: undefined;
|
|
39
|
+
if (!Array.isArray(references)) return undefined;
|
|
40
|
+
return new Set(
|
|
41
|
+
references.flatMap((entry: unknown) => {
|
|
42
|
+
const value =
|
|
43
|
+
typeof entry === 'object' && entry !== null
|
|
44
|
+
? (entry as { path?: unknown }).path
|
|
45
|
+
: undefined;
|
|
46
|
+
return typeof value === 'string' ? [normalizeReferencePath(value)] : [];
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The edit, spelled out: the array to add to, the entry to add, and the command that proves it
|
|
53
|
+
* took. `tsc -b` is the same one the gate's `typecheck` step runs, so a reader who runs the fix
|
|
54
|
+
* runs the check.
|
|
55
|
+
*/
|
|
56
|
+
export const unreferencedFinding = (dir: string): Finding => ({
|
|
57
|
+
code: 'X_PACKAGE_UNREFERENCED',
|
|
58
|
+
cause: `packages/${dir} is a published workspace and ${ROOT_TSCONFIG} has no reference to it, so tsc -b never builds it`,
|
|
59
|
+
fix: `add { "path": "./packages/${dir}" } to "references" in ${ROOT_TSCONFIG}, then run bunx tsc -b --pretty false`,
|
|
60
|
+
docs: docsFor('X_PACKAGE_UNREFERENCED'),
|
|
61
|
+
at: ROOT_TSCONFIG,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Published workspaces only. A private package is not a shipped contract and a generated app's
|
|
66
|
+
* `packages/*` are all private, so this asks nothing of an app that never publishes — the same
|
|
67
|
+
* line `checkPublishShape` already draws.
|
|
68
|
+
*/
|
|
69
|
+
export async function checkRootReferences(
|
|
70
|
+
root: string,
|
|
71
|
+
dirs: readonly string[],
|
|
72
|
+
): Promise<readonly Finding[]> {
|
|
73
|
+
const referenced = await referencedPaths(root);
|
|
74
|
+
if (referenced === undefined) return [];
|
|
75
|
+
return dirs
|
|
76
|
+
.filter((dir) => !referenced.has(`packages/${dir}`))
|
|
77
|
+
.map((dir) => unreferencedFinding(dir));
|
|
78
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The suite ratchet: the steps this repo has already proved it can run, committed as a floor.
|
|
2
|
+
// `applies` answers "is there anything to check here?", and a deleted suite answers "no" — which
|
|
3
|
+
// reads as a skip and keeps the gate green. The floor turns that skip back into a failure. Read
|
|
4
|
+
// here and written by nothing: a gate that edits its own floor ratchets both ways, which is none.
|
|
5
|
+
|
|
6
|
+
// Bun ships no equivalent for either: `existsSync` answers whether this root committed a floor at
|
|
7
|
+
// all, and `join` builds the host-separator path to it.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { docsFor } from './error-codes';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
13
|
+
|
|
14
|
+
/** Hand-written and committed, beside the generated `x.manifest.json` the same gate reads. */
|
|
15
|
+
export const VERIFY_FLOOR_FILE = 'x.verify.json';
|
|
16
|
+
|
|
17
|
+
export interface VerifyFloor {
|
|
18
|
+
/** Declared step names this run may not report as skipped. */
|
|
19
|
+
readonly steps: readonly string[];
|
|
20
|
+
/** Why part of the file is not a floor. The `manifest` step reports these; nothing swallows them. */
|
|
21
|
+
readonly problems: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
25
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
? (value as Record<string, unknown>)
|
|
27
|
+
: undefined;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The file's names, split into the ones the ratchet can enforce and the reasons the rest cannot be.
|
|
31
|
+
* A name no step declares is dropped rather than enforced — it can never apply, so enforcing it
|
|
32
|
+
* would pin the gate red forever — and it is returned as a problem, because a floor with a typo in
|
|
33
|
+
* it silently covers nothing, which is the same false green the floor exists to close.
|
|
34
|
+
*
|
|
35
|
+
* `declared` is a parameter rather than the imported constant at the call site, so a test can pin a
|
|
36
|
+
* closed world instead of asserting against every step this build happens to ship.
|
|
37
|
+
*/
|
|
38
|
+
export function parseVerifyFloor(
|
|
39
|
+
text: string,
|
|
40
|
+
declared: readonly string[] = VERIFY_STEP_NAMES,
|
|
41
|
+
): VerifyFloor {
|
|
42
|
+
let payload: unknown;
|
|
43
|
+
try {
|
|
44
|
+
payload = JSON.parse(text);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
47
|
+
return { steps: [], problems: [`it does not parse as JSON (${reason})`] };
|
|
48
|
+
}
|
|
49
|
+
const steps = asRecord(payload)?.['steps'];
|
|
50
|
+
if (!Array.isArray(steps)) {
|
|
51
|
+
return { steps: [], problems: ['it has no "steps" array of step names'] };
|
|
52
|
+
}
|
|
53
|
+
const named = steps.filter((step): step is string => typeof step === 'string');
|
|
54
|
+
const unknown = named.filter((step) => !declared.includes(step));
|
|
55
|
+
return {
|
|
56
|
+
steps: named.filter((step) => declared.includes(step)),
|
|
57
|
+
problems: [
|
|
58
|
+
...(named.length === steps.length ? [] : ['"steps" holds an entry that is not a string']),
|
|
59
|
+
...(unknown.length === 0
|
|
60
|
+
? []
|
|
61
|
+
: [`"steps" names ${unknown.join(', ')}, which x verify does not run`]),
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** No file is no floor: a repo that never committed one is not ratcheted, and reports nothing. */
|
|
67
|
+
export async function readVerifyFloor(root: string): Promise<VerifyFloor | undefined> {
|
|
68
|
+
const path = join(root, VERIFY_FLOOR_FILE);
|
|
69
|
+
if (!existsSync(path)) return undefined;
|
|
70
|
+
const text = await Bun.file(path)
|
|
71
|
+
.text()
|
|
72
|
+
.catch(() => undefined);
|
|
73
|
+
if (text === undefined) return { steps: [], problems: ['it could not be read as a file'] };
|
|
74
|
+
return parseVerifyFloor(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the floor already claims this step has something to check in this repo. */
|
|
78
|
+
export const floorRequires = (floor: VerifyFloor | undefined, step: string): boolean =>
|
|
79
|
+
floor?.steps.includes(step) === true;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A step the floor requires that found nothing to run. Named for what happened rather than for the
|
|
83
|
+
* file — the suite is what vanished — and the fix carries both edits that resolve it, because
|
|
84
|
+
* either can be right: the suite comes back, or the floor drops a line in a commit that says why.
|
|
85
|
+
*
|
|
86
|
+
* Command first, alternatives behind a `#`, so the line runs verbatim and the shell drops the rest
|
|
87
|
+
* (the shape `mcp-errors.ts` gives this same code). Neither edit is scripted here on purpose: a
|
|
88
|
+
* command that rewrites the floor is the gate editing its own ratchet, which is the false green the
|
|
89
|
+
* floor exists to close — so the run that proves the step is back is what this offers to repeat.
|
|
90
|
+
*/
|
|
91
|
+
export const vanishedSuiteFinding = (step: string): Finding => ({
|
|
92
|
+
code: 'X_VERIFY_SUITE_VANISHED',
|
|
93
|
+
cause: `${VERIFY_FLOOR_FILE} requires the ${step} step and this run found nothing for it to check`,
|
|
94
|
+
fix: `x verify --json # restore the ${step} suite, or drop "${step}" from ${VERIFY_FLOOR_FILE} in the commit that says why`,
|
|
95
|
+
docs: docsFor('X_VERIFY_SUITE_VANISHED'),
|
|
96
|
+
at: VERIFY_FLOOR_FILE,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The second way a suite vanishes, and the reason this is the same code rather than a new one: a
|
|
101
|
+
* step whose files are all still there and whose every test skipped itself found exactly as much
|
|
102
|
+
* for the floor to stand on as a step with no files — nothing. `applies` cannot see it, because
|
|
103
|
+
* `describe.skipIf` is decided inside the run, so this is read back out of what bun reported.
|
|
104
|
+
*
|
|
105
|
+
* The fix names the suite's own command first: what an author has to change is the environment the
|
|
106
|
+
* tests skip without (`TEST_DATABASE_URL` for `live`), and running the type alone is how they find
|
|
107
|
+
* out which one. Dropping the step from the floor stays the alternative, behind the `#`, because
|
|
108
|
+
* either can be the right answer and neither may be performed by the gate on its own ratchet.
|
|
109
|
+
*/
|
|
110
|
+
export const skippedSuiteFinding = (step: string, skipped: number): Finding => ({
|
|
111
|
+
code: 'X_VERIFY_SUITE_VANISHED',
|
|
112
|
+
cause: `${VERIFY_FLOOR_FILE} requires the ${step} step and all ${skipped} test(s) it found skipped themselves, so nothing ran`,
|
|
113
|
+
fix: `x test ${step} --json # then set what the suite skips without, or drop "${step}" from ${VERIFY_FLOOR_FILE} in the commit that says why`,
|
|
114
|
+
docs: docsFor('X_VERIFY_SUITE_VANISHED'),
|
|
115
|
+
at: VERIFY_FLOOR_FILE,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The floor file's own integrity, as findings. `X_CONFIG_INVALID` rather than a second code of this
|
|
120
|
+
* package's own: a committed file the framework reads and cannot use is exactly what core already
|
|
121
|
+
* named, and a floor that enforces nothing is not a vanished suite.
|
|
122
|
+
*
|
|
123
|
+
* The command runs first because it is what the edit needs: the step table it prints is the closed
|
|
124
|
+
* set of names the floor may hold, so an author fixing a typo reads the answer instead of guessing.
|
|
125
|
+
*/
|
|
126
|
+
export const floorProblemFindings = (floor: VerifyFloor | undefined): readonly Finding[] =>
|
|
127
|
+
(floor?.problems ?? []).map((problem) => ({
|
|
128
|
+
code: 'X_CONFIG_INVALID',
|
|
129
|
+
cause: `${VERIFY_FLOOR_FILE} is not a suite floor: ${problem}`,
|
|
130
|
+
fix: `x verify --json # then write ${VERIFY_FLOOR_FILE} as {"steps":["unit","contract"]}, naming only steps it ran`,
|
|
131
|
+
docs: docsFor('X_CONFIG_INVALID'),
|
|
132
|
+
at: VERIFY_FLOOR_FILE,
|
|
133
|
+
}));
|
package/src/verify-step.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ExecResult, Runner } from './exec';
|
|
6
6
|
import { execOutput } from './exec';
|
|
7
7
|
import type { Finding } from './output';
|
|
8
|
+
import type { TestCounts } from './test-counts';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Every step of the gate, in cost order — cheapest and most informative first, and never a check
|
|
@@ -44,12 +45,30 @@ export interface VerifyContext {
|
|
|
44
45
|
readonly root: string;
|
|
45
46
|
readonly runner: Runner;
|
|
46
47
|
readonly hostChecks?: Partial<Record<VerifyStepName, HostCheck>>;
|
|
48
|
+
/**
|
|
49
|
+
* How wide the parallel test steps go. Absent means `defaultWorkers()` — a knob, never a
|
|
50
|
+
* narrowing: no value of it changes which steps run or what "green" means, which is why this is
|
|
51
|
+
* the only flag `x verify` accepts beyond the global ones.
|
|
52
|
+
*/
|
|
53
|
+
readonly workers?: number;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
export interface StepOutcome {
|
|
50
57
|
readonly ok: boolean;
|
|
51
58
|
readonly findings: readonly Finding[];
|
|
52
59
|
readonly output?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Processes this step actually used. `1` is a step that ran serially, and a reader has to be
|
|
62
|
+
* able to tell "serial because nothing could isolate it" from "parallel and fast" without
|
|
63
|
+
* reading the step list's source.
|
|
64
|
+
*/
|
|
65
|
+
readonly workers?: number;
|
|
66
|
+
/**
|
|
67
|
+
* What the suite executed, for the steps that run one. Absent means this step spawned no test
|
|
68
|
+
* process at all — a check step, or the `eval` step answering with declarations alone — and is
|
|
69
|
+
* NOT the same as a suite that ran nothing, which is a number the ratchet acts on.
|
|
70
|
+
*/
|
|
71
|
+
readonly tests?: TestCounts;
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
export interface VerifyStep {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Running one test type across N worker processes and reporting it as one gate step. Split from
|
|
2
|
+
// verify-tests.ts because that file owns which files belong to a type and this one owns what
|
|
3
|
+
// happens to them once selected — a wrong file list is never a race, and a race is never a
|
|
4
|
+
// selection bug.
|
|
5
|
+
|
|
6
|
+
import { docsFor } from './error-codes';
|
|
7
|
+
import type { Runner } from './exec';
|
|
8
|
+
import { execOutput } from './exec';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { countsOf } from './test-counts';
|
|
11
|
+
import type { TestFile } from './test-select';
|
|
12
|
+
import { planShards, reproduceFor, shardArgs } from './test-shards';
|
|
13
|
+
import type { StepOutcome } from './verify-step';
|
|
14
|
+
// Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
|
|
15
|
+
import type { TestType } from './verify-tests';
|
|
16
|
+
|
|
17
|
+
export interface ParallelRunOptions {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
readonly runner: Runner;
|
|
20
|
+
readonly files: readonly TestFile[];
|
|
21
|
+
/** The ask. `planShards` clamps it to the file count, and the report carries what it became. */
|
|
22
|
+
readonly workers: number;
|
|
23
|
+
/** Carried into the `fix:` so a failed shard reproduces as `x test <type> --workers N …`. */
|
|
24
|
+
readonly type: TestType;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The whole point is wall-clock, so every shard starts at once. Two things make that safe and
|
|
29
|
+
* neither is optional: `shardArgs` gives each FILE its own module registry (`--isolate`), and
|
|
30
|
+
* `ULTIMATE_TEST_WORKER` gives each PROCESS its own database — `@ultimat3/testing`'s
|
|
31
|
+
* `acquireWorkerDatabase` reads exactly that variable first and clones the migrated template into
|
|
32
|
+
* `…_w<index>`. Rails' numbered test databases, with Postgres doing the copy.
|
|
33
|
+
*/
|
|
34
|
+
export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
|
|
35
|
+
const shards = planShards(options.files, options.workers);
|
|
36
|
+
const runs = await Promise.all(
|
|
37
|
+
shards.map(async (shard) => ({
|
|
38
|
+
shard,
|
|
39
|
+
result: await options.runner(shardArgs(shard), {
|
|
40
|
+
cwd: options.root,
|
|
41
|
+
env: { ULTIMATE_TEST_WORKER: String(shard.index) },
|
|
42
|
+
}),
|
|
43
|
+
})),
|
|
44
|
+
);
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
for (const { shard, result } of runs) {
|
|
47
|
+
if (result.ok) continue;
|
|
48
|
+
findings.push({
|
|
49
|
+
code: 'X_TEST_SHARD_FAILED',
|
|
50
|
+
cause: `${options.type} shard ${shard.index} of ${shards.length} exited ${result.code} (${shard.files.length} file(s))`,
|
|
51
|
+
// The reproduction has to name every input to the split, or it reruns a different file set:
|
|
52
|
+
// `reproduceFor` is the one place that rule lives, shared with `x test`.
|
|
53
|
+
fix: reproduceFor(shard, { workers: shards.length, type: options.type }),
|
|
54
|
+
docs: docsFor('X_TEST_SHARD_FAILED'),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Only the failing shards' output: a green 8-way split would otherwise print eight summaries,
|
|
58
|
+
// and the reader of a red gate needs the assertion diff, not the seven runs that passed.
|
|
59
|
+
const output = runs
|
|
60
|
+
.filter((run) => !run.result.ok)
|
|
61
|
+
.map((run) => `— shard ${run.shard.index}\n${execOutput(run.result)}`)
|
|
62
|
+
.join('\n');
|
|
63
|
+
return {
|
|
64
|
+
ok: findings.length === 0,
|
|
65
|
+
findings,
|
|
66
|
+
workers: shards.length,
|
|
67
|
+
// Every shard's summary, including the green ones whose output is dropped above: the counts
|
|
68
|
+
// are how the ratchet tells a suite that passed from a suite that skipped itself to nothing.
|
|
69
|
+
tests: countsOf(runs.map((run) => run.result)),
|
|
70
|
+
...(output === '' ? {} : { output }),
|
|
71
|
+
};
|
|
72
|
+
}
|