@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -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 +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- 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 +14 -8
- 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 +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- 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 +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -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 +92 -15
- 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 +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- 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 +185 -13
- package/src/shell-quote.ts +15 -0
- 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 +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The modules a feature slice owns — `entity.ts`, `repo.ts`, `policy.ts`, `errors.ts` — composed by
|
|
2
|
+
// every generator that imports one. `x g resource` already wrote them by composing `entityFiles` +
|
|
3
|
+
// `policyFiles`; the five generators that write *into* a slice imported the same files and wrote
|
|
4
|
+
// none of them, so each emitted TS2307 in any slice a resource had not been run in first.
|
|
5
|
+
|
|
6
|
+
import type { FeatureTarget } from './entity';
|
|
7
|
+
import { entityFiles } from './entity';
|
|
8
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
9
|
+
import { names } from './naming';
|
|
10
|
+
import { policyFiles } from './policy';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which slice modules a generator's own source imports. Named per generator rather than emitted as
|
|
14
|
+
* one fixed set: a job imports `../repo` and evaluates no policy, and a generated `policy.ts` it
|
|
15
|
+
* never reads is a file an author has to read before deleting.
|
|
16
|
+
*
|
|
17
|
+
* `'entity'` is the pair, not the file: `repo.ts` imports `./entity` for its row type, so emitting
|
|
18
|
+
* one without the other moves the unresolved import rather than closing it.
|
|
19
|
+
*/
|
|
20
|
+
export type SliceModule = 'entity' | 'policy' | 'errors';
|
|
21
|
+
|
|
22
|
+
/** The feature's own code, derived once. The `docs:` URL used to be the literal
|
|
23
|
+
* `.../X_NOT_FOUND` beside a `code:` of `X_INVOICE_NOT_FOUND`, so following the link from a real
|
|
24
|
+
* failure landed on a different code's page — the same interpolation `error-codes.ts`'s `docsFor`
|
|
25
|
+
* already does for every framework code. */
|
|
26
|
+
const notFoundCode = (feature: NameSet): string =>
|
|
27
|
+
`X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The emitted `fix:` cites `x queries list`, and which command it cites is the whole point: a
|
|
31
|
+
* scaffolded app runs the same `errors` step this repo does, so a fix naming a command the build
|
|
32
|
+
* does not ship writes a fresh X_ERROR_FIX_INVALID into the app on every `x g action`. It cited
|
|
33
|
+
* `x db studio`, which is in `PLANNED_SUBCOMMANDS` and exits X_NOT_IMPLEMENTED — the generator was
|
|
34
|
+
* breaking the one rule it exists to demonstrate. `x queries list` ships, and a read is where a
|
|
35
|
+
* caller gets an id that exists.
|
|
36
|
+
*/
|
|
37
|
+
const errorsSource = (feature: NameSet): string => {
|
|
38
|
+
const errorCode = notFoundCode(feature);
|
|
39
|
+
return `// The ${feature.kebab} feature's X_* codes. Never throw a bare Error: an agent reading the failure
|
|
40
|
+
// needs the code, the cause and the exact command that fixes it.
|
|
41
|
+
|
|
42
|
+
import { UltimateError } from '@ultimat3/core';
|
|
43
|
+
|
|
44
|
+
export class ${feature.pascal}NotFoundError extends UltimateError {
|
|
45
|
+
constructor(input: { id: string }) {
|
|
46
|
+
super({
|
|
47
|
+
code: '${errorCode}',
|
|
48
|
+
cause: \`no ${feature.kebab} with id \${input.id}\`,
|
|
49
|
+
fix: 'x queries list --json, then pass an id the ${feature.kebab} read returns',
|
|
50
|
+
docs: 'https://ultimate.dev/errors/${errorCode}',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Re-tags a slice module as one the writer may skip. The byte-carrying variant is passed through
|
|
59
|
+
* untouched rather than cast: only the scaffolded app icon carries bytes and no slice module is a
|
|
60
|
+
* PNG, so a future one would be a visible hard write instead of a silent claim it was checked.
|
|
61
|
+
*/
|
|
62
|
+
const ifAbsent = (files: readonly GeneratedFile[]): readonly GeneratedFile[] =>
|
|
63
|
+
files.map((file) =>
|
|
64
|
+
typeof file.contents === 'string'
|
|
65
|
+
? { path: file.path, contents: file.contents, merge: 'if-absent' as const }
|
|
66
|
+
: file,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The slice modules `needs` names, in the order a reader meets them: the table, then the authz,
|
|
71
|
+
* then the failures. Named from `target.feature` and never from the primitive's own name — the
|
|
72
|
+
* generated `import { InvoiceNotFoundError } from '../errors'` is the feature's type, not
|
|
73
|
+
* `send-invoice`'s.
|
|
74
|
+
*/
|
|
75
|
+
export function sliceFoundation(
|
|
76
|
+
target: FeatureTarget,
|
|
77
|
+
needs: readonly SliceModule[],
|
|
78
|
+
): readonly GeneratedFile[] {
|
|
79
|
+
const feature = names(target.feature);
|
|
80
|
+
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
81
|
+
return ifAbsent([
|
|
82
|
+
...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
|
|
83
|
+
...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
|
|
84
|
+
...(needs.includes('errors')
|
|
85
|
+
? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
|
|
86
|
+
: []),
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Emitting a line the way Biome would print it. A template cannot run a formatter, so generated
|
|
2
|
+
// source is written pre-formatted — and a FIXED shape is wrong for one name length or the other:
|
|
3
|
+
// Biome joins a short wrapped call back onto one line and breaks a long joined one, and the app's
|
|
4
|
+
// own `lint` step fails on whichever half the template guessed wrong.
|
|
5
|
+
|
|
6
|
+
/** The scaffold's own `biome.json` says `lineWidth: 100`, and this is the same number. */
|
|
7
|
+
export const LINE_WIDTH = 100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `<open><a>, <b><close>` when it fits, one entry per line with a trailing comma when it does not.
|
|
11
|
+
* The one shape behind every generated argument list, array literal and parameter list — a second
|
|
12
|
+
* copy of this rule is how `x g entity credit-note-attachment` shipped a `$view([…])` line that the
|
|
13
|
+
* app's formatter rewrote on sight.
|
|
14
|
+
*/
|
|
15
|
+
export function wrapList(
|
|
16
|
+
indent: string,
|
|
17
|
+
open: string,
|
|
18
|
+
entries: readonly string[],
|
|
19
|
+
close: string,
|
|
20
|
+
): string {
|
|
21
|
+
const joined = `${indent}${open}${entries.join(', ')}${close}`;
|
|
22
|
+
if (joined.length <= LINE_WIDTH) return joined;
|
|
23
|
+
const body = entries.map((entry) => `${indent} ${entry},`).join('\n');
|
|
24
|
+
return `${indent}${open}\n${body}\n${indent}${close}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isDigit = (char: string): boolean => char >= '0' && char <= '9';
|
|
28
|
+
|
|
29
|
+
/** How many digits run from `at`. A run is compared as a number, so `b2` precedes `b10`. */
|
|
30
|
+
const digitRun = (text: string, at: number): number => {
|
|
31
|
+
let end = at;
|
|
32
|
+
while (end < text.length && isDigit(text[end] ?? '')) end += 1;
|
|
33
|
+
return end - at;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Biome's own order for the names inside `{ … }`, measured against 2.5.8 rather than assumed —
|
|
38
|
+
* three behaviours, and no single-pass compare gives all three:
|
|
39
|
+
*
|
|
40
|
+
* `{ post, PostView }` → `{ PostView, post }` upper first on a pure case tie
|
|
41
|
+
* `{ Zeta, alpha }` → `{ alpha, Zeta }` but case is only ever the TIEBREAK
|
|
42
|
+
* `{ b10, b2 }` → `{ b2, b10 }` a digit run compares as a number
|
|
43
|
+
*
|
|
44
|
+
* The first two together are why folding to lower case and comparing is wrong: `post` is a prefix
|
|
45
|
+
* of `postview`, so a folded compare puts `post` first and Biome does not.
|
|
46
|
+
*/
|
|
47
|
+
const compareSpecifiers = (left: string, right: string): number => {
|
|
48
|
+
let a = 0;
|
|
49
|
+
let b = 0;
|
|
50
|
+
while (a < left.length && b < right.length) {
|
|
51
|
+
const runA = digitRun(left, a);
|
|
52
|
+
const runB = digitRun(right, b);
|
|
53
|
+
if (runA > 0 && runB > 0) {
|
|
54
|
+
const valueA = Number(left.slice(a, a + runA));
|
|
55
|
+
const valueB = Number(right.slice(b, b + runB));
|
|
56
|
+
if (valueA !== valueB) return valueA - valueB;
|
|
57
|
+
a += runA;
|
|
58
|
+
b += runB;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const charA = left[a] ?? '';
|
|
62
|
+
const charB = right[b] ?? '';
|
|
63
|
+
const foldedA = charA.toLowerCase();
|
|
64
|
+
const foldedB = charB.toLowerCase();
|
|
65
|
+
if (foldedA !== foldedB) return foldedA < foldedB ? -1 : 1;
|
|
66
|
+
// Same letter, different case: uppercase wins HERE rather than after the whole string, which
|
|
67
|
+
// is what makes `PostView` precede `post` instead of following it.
|
|
68
|
+
if (charA !== charB) return charA < charB ? -1 : 1;
|
|
69
|
+
a += 1;
|
|
70
|
+
b += 1;
|
|
71
|
+
}
|
|
72
|
+
return left.length - right.length;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The names of an import, in the order Biome would leave them. `assist/source/organizeImports` is
|
|
77
|
+
* an ERROR under the scaffold's config, and the order is decided by the FEATURE NAME, not by the
|
|
78
|
+
* template: `x g action audit-log --feature billing` emitted `{ canBillingWrite, billingTag }`, so
|
|
79
|
+
* every feature starting `a` or `b` wrote a failing `lint` step into the app. Sorted here rather
|
|
80
|
+
* than spelled in each template, because the correct spelling is not knowable at authoring time.
|
|
81
|
+
*/
|
|
82
|
+
export const sortSpecifiers = (names: readonly string[]): readonly string[] =>
|
|
83
|
+
[...names].sort(compareSpecifiers);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A named import, sorted and wrapped. Its own function because the braces are spaced on one line
|
|
87
|
+
* (`import { a, b } from …`) and not spaced when broken — `wrapList` would emit `import {a, b}`
|
|
88
|
+
* joined, or a stray space before the closing brace when broken.
|
|
89
|
+
*/
|
|
90
|
+
export function wrapImport(names: readonly string[], from: string): string {
|
|
91
|
+
const sorted = sortSpecifiers(names);
|
|
92
|
+
const joined = `import { ${sorted.join(', ')} } from '${from}';`;
|
|
93
|
+
if (joined.length <= LINE_WIDTH) return joined;
|
|
94
|
+
return `import {\n${sorted.map((name) => ` ${name},`).join('\n')}\n} from '${from}';`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What a test step actually executed, read back out of `bun test`'s own summary. Separate from
|
|
2
|
+
// the two runners because both of them need it and neither owns it: a step's exit code answers
|
|
3
|
+
// "did anything fail", and only the counts answer "did anything run".
|
|
4
|
+
|
|
5
|
+
import type { ExecResult } from './exec';
|
|
6
|
+
import { execOutput } from './exec';
|
|
7
|
+
import { parseBunTest } from './mcp-test-output';
|
|
8
|
+
|
|
9
|
+
export interface TestCounts {
|
|
10
|
+
/** Tests that executed — passed plus failed. A failed test is a test that ran. */
|
|
11
|
+
readonly ran: number;
|
|
12
|
+
/** Tests bun reported as skipped or todo, which is the same thing here: they did not run. */
|
|
13
|
+
readonly skipped: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Summed across every process the step spawned, because a step is one line in the gate whether it
|
|
18
|
+
* ran on one worker or eight.
|
|
19
|
+
*
|
|
20
|
+
* `parseBunTest` is the reader, not a second regex: it is already the one place this repo turns
|
|
21
|
+
* bun's summary into numbers (`x mcp`'s `test.run`), and two readers of one format is exactly the
|
|
22
|
+
* drift the CLI's own boundary rule forbids. It reports output it cannot recognise as one FAILED
|
|
23
|
+
* test rather than as zeros — which is what keeps a runner that died before printing a summary
|
|
24
|
+
* from reading as a suite that ran nothing.
|
|
25
|
+
*/
|
|
26
|
+
export const countsOf = (results: readonly ExecResult[]): TestCounts => {
|
|
27
|
+
let ran = 0;
|
|
28
|
+
let skipped = 0;
|
|
29
|
+
for (const result of results) {
|
|
30
|
+
const run = parseBunTest(execOutput(result), result.durationMs);
|
|
31
|
+
ran += run.passed + run.failed;
|
|
32
|
+
skipped += run.skipped;
|
|
33
|
+
}
|
|
34
|
+
return { ran, skipped };
|
|
35
|
+
};
|
package/src/test-select.ts
CHANGED
|
@@ -9,27 +9,41 @@ import { BadFlagError } from './errors';
|
|
|
9
9
|
import type { ParsedArgs } from './parse';
|
|
10
10
|
import { flagString, nearest } from './parse';
|
|
11
11
|
import type { TestType } from './verify-tests';
|
|
12
|
-
import {
|
|
12
|
+
import { ownerOf, TEST_TYPES } from './verify-tests';
|
|
13
13
|
|
|
14
14
|
export interface TestFile {
|
|
15
15
|
readonly path: string;
|
|
16
16
|
readonly bytes: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `.tsx` too. A JSX test was silently outside the gate's parallel steps — `discoverTests` never
|
|
21
|
+
* yielded it, so `runParallel` spawned `bun test` over an explicit file list that did not include
|
|
22
|
+
* it and the step reported green over a file that never executed, while `bun run test` at the root
|
|
23
|
+
* ran it and failed. `testStepCommand`'s own ignore patterns already say `.test.*`.
|
|
24
|
+
*/
|
|
25
|
+
const TEST_GLOB = '**/*.test.{ts,tsx}';
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* The root `test` script's ignore list, kept identical so `x test` and `bun run test` see one
|
|
23
29
|
* suite. `e2e/` is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is
|
|
24
30
|
* a suite nobody runs until CI says so. `examples/` is, because the reference app is a separate
|
|
25
31
|
* project with its own gate — `x verify` there, not `x test` here.
|
|
32
|
+
*
|
|
33
|
+
* `dummy/` and `build/` complete the list `verify-tests.ts` already excluded (`NEVER_A_TEST`). The
|
|
34
|
+
* comment above claimed the two agreed and they did not: `x test unit` discovered 464 files where
|
|
35
|
+
* the gate's `unit` step ran 441, so the gate's own test steps — which now select through this
|
|
36
|
+
* function — would have started running a nested demo app's suite on the framework's gate.
|
|
37
|
+
*
|
|
38
|
+
* Both directories are gated where they belong, by `scripts/reference-app-gate.ts` running
|
|
39
|
+
* `x verify` inside each app — see `NEVER_A_TEST` for why that is the only place they run.
|
|
26
40
|
*/
|
|
27
|
-
const IGNORED = ['/dist/', '/node_modules/', '/examples/'];
|
|
41
|
+
const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/'];
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
44
|
* File size stands in for duration: cheap to read, and it correlates far better than file count.
|
|
31
|
-
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite —
|
|
32
|
-
* `
|
|
45
|
+
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
|
|
46
|
+
* owner per path, decided by `ownerOf` there and never a second time here.
|
|
33
47
|
*/
|
|
34
48
|
export async function discoverTests(
|
|
35
49
|
root: string,
|
|
@@ -65,18 +79,19 @@ export function sampleFiles(files: readonly TestFile[], sample: number): readonl
|
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
/**
|
|
68
|
-
* verify-tests.ts owns the one definition of what a file's test type is
|
|
69
|
-
*
|
|
70
|
-
* would
|
|
82
|
+
* verify-tests.ts owns the one definition of what a file's test type is, and `ownerOf` IS that
|
|
83
|
+
* definition — one owner per path, `unit` for anything no typed rule claims. Re-deciding it here
|
|
84
|
+
* would be a second answer, and the two would disagree the first time a suite's naming rule
|
|
85
|
+
* changed: a file both of them claimed would run in two steps of one gate.
|
|
86
|
+
*
|
|
87
|
+
* Asked lazily, and that is load-bearing: verify-tests.ts imports this module (the gate's test
|
|
88
|
+
* steps select their files through `discoverTests`), so the two form a cycle. Anything here that
|
|
89
|
+
* READ that module at import time would evaluate inside its temporal dead zone, and whichever side
|
|
90
|
+
* is imported first dies with "Cannot access 'TEST_TYPES' before initialization" — measured by
|
|
91
|
+
* `bun run manifest`, which imports the CLI and took the crash.
|
|
71
92
|
*/
|
|
72
|
-
const TYPE_FILTERS: readonly (readonly [Exclude<TestType, 'unit'>, string])[] = TEST_TYPES.filter(
|
|
73
|
-
(type): type is Exclude<TestType, 'unit'> => type !== 'unit',
|
|
74
|
-
).map((type) => [type, typeFilterOf(type)] as const);
|
|
75
|
-
|
|
76
|
-
/** unit is everything the five typed suites do not claim, so no file falls between two types. */
|
|
77
93
|
export function belongsToType(path: string, type: TestType): boolean {
|
|
78
|
-
|
|
79
|
-
return TYPE_FILTERS.some(([typed, filter]) => typed === type && path.includes(filter));
|
|
94
|
+
return ownerOf(path) === type;
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
/**
|
package/src/test-shards.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// cmd-test.ts because a printed reproduction is only true if it carries every input to the split —
|
|
4
4
|
// that rule is this file's, and argv parsing is that one's.
|
|
5
5
|
|
|
6
|
-
import { docsFor } from './
|
|
6
|
+
import { docsFor } from './error-codes';
|
|
7
7
|
import type { Runner } from './exec';
|
|
8
8
|
import { execOutput } from './exec';
|
|
9
9
|
import { msg } from './messages';
|
|
10
10
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
11
|
+
import { quoteArg } from './shell-quote';
|
|
11
12
|
import type { TestFile } from './test-select';
|
|
12
13
|
import { bySizeThenPath } from './test-select';
|
|
13
14
|
import type { TestType } from './verify-tests';
|
|
@@ -43,18 +44,26 @@ export function planShards(files: readonly TestFile[], workers: number): readonl
|
|
|
43
44
|
}));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Explicit file list, so the child never re-globs and can never pick up another shard's files. */
|
|
47
|
-
export const shardArgs = (shard: Shard): readonly string[] => ['bun', 'test', ...shard.files];
|
|
48
|
-
|
|
49
|
-
const SHELL_SAFE = /^[\w@%+=:,./-]+$/;
|
|
50
|
-
|
|
51
47
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
48
|
+
* Explicit file list, so the child never re-globs and can never pick up another shard's files.
|
|
49
|
+
*
|
|
50
|
+
* `--isolate` is what makes an arbitrary partition safe, and it is not optional. Half a dozen
|
|
51
|
+
* registries in this framework are process-global by design — the permission set, the roles, the
|
|
52
|
+
* entity/action/query tables, the error-code titles, the fixture bag — and a serial `bun test`
|
|
53
|
+
* only passes because glob order happens to put every declaring file before every file that reads
|
|
54
|
+
* what it left behind. Re-partition the same files and that accident is gone: measured on this
|
|
55
|
+
* repo, an 8-way split turned 0 failures into 36, all of them `X_PERMISSION_UNKNOWN` in
|
|
56
|
+
* `@ultimat3/query` because the `packages/cli` file that had been declaring `feed:read` for it
|
|
57
|
+
* landed in another shard. A fresh module registry per FILE removes the channel entirely, so the
|
|
58
|
+
* split can be any split. The database is isolated per WORKER, not per file — one cloned template
|
|
59
|
+
* per process, which is `ULTIMATE_TEST_WORKER` below.
|
|
55
60
|
*/
|
|
56
|
-
export const
|
|
57
|
-
|
|
61
|
+
export const SHARD_COMMAND_PREFIX = ['bun', 'test', '--isolate'] as const;
|
|
62
|
+
|
|
63
|
+
export const shardArgs = (shard: Shard): readonly string[] => [
|
|
64
|
+
...SHARD_COMMAND_PREFIX,
|
|
65
|
+
...shard.files,
|
|
66
|
+
];
|
|
58
67
|
|
|
59
68
|
export interface ReproduceOptions {
|
|
60
69
|
/** The *effective* worker count: `planShards` clamps to the file count, and the split follows. */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// How wide a parallel test run goes, decided in one place. `x test --workers`, `x verify
|
|
2
|
+
// --workers` and every parallel step of the gate read this — a second default would split the same
|
|
3
|
+
// suite two different ways, and the `--worker N` reproduction a shard failure prints would then
|
|
4
|
+
// name a shard the gate never ran.
|
|
5
|
+
|
|
6
|
+
// Bun ships no CPU-count primitive; `cpus()` is the fallback when navigator cannot answer.
|
|
7
|
+
import { cpus } from 'node:os';
|
|
8
|
+
|
|
9
|
+
/** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
|
|
10
|
+
export function availableCpus(): number {
|
|
11
|
+
const hinted = typeof navigator === 'undefined' ? Number.NaN : navigator.hardwareConcurrency;
|
|
12
|
+
return Math.max(1, Number.isFinite(hinted) && hinted > 0 ? Math.trunc(hinted) : cpus().length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deliberately MORE workers than cores, with a ceiling.
|
|
17
|
+
*
|
|
18
|
+
* `cpus - 1` is the intuitive default and it was measured to be worthless exactly where it has to
|
|
19
|
+
* pay off. On a 4-core `ubuntu-latest` — the runner this repo commits to — the `unit` step:
|
|
20
|
+
*
|
|
21
|
+
* | workers | wall |
|
|
22
|
+
* |---------|-------|
|
|
23
|
+
* | serial | 43.2s |
|
|
24
|
+
* | 3 (cpus - 1) | 44.8s | <- the old default: slower than not sharding at all
|
|
25
|
+
* | 4 | 41.6s |
|
|
26
|
+
* | 6 | 34.8s |
|
|
27
|
+
*
|
|
28
|
+
* Three workers on four cores loses to serial because sharding is not free — each worker reloads
|
|
29
|
+
* the framework's module graph — and three of them cannot cover that cost. The reason more-than-
|
|
30
|
+
* cores wins is that a test worker is not CPU-bound end to end: it spends real time on module
|
|
31
|
+
* resolution, on `--isolate` rebuilding a registry per file, and on waiting for its database.
|
|
32
|
+
* Oversubscribing fills those stalls.
|
|
33
|
+
*
|
|
34
|
+
* The ceiling is memory, not cores. A worker is a whole Bun process with the framework's module
|
|
35
|
+
* graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite, so
|
|
36
|
+
* width costs hundreds of MB per step. It binds on a developer's 12- or 32-core machine, which is
|
|
37
|
+
* exactly where an unbounded count would swap.
|
|
38
|
+
*
|
|
39
|
+
* The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
|
|
40
|
+
*/
|
|
41
|
+
export const WORKER_CEILING = 8;
|
|
42
|
+
|
|
43
|
+
/** Oversubscription factor. See the table above — it is measured, not chosen for roundness. */
|
|
44
|
+
export const WORKER_OVERSUBSCRIBE = 1.5;
|
|
45
|
+
|
|
46
|
+
/** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
|
|
47
|
+
export const WORKER_FLOOR = 2;
|
|
48
|
+
|
|
49
|
+
export const defaultWorkers = (available: number = availableCpus()): number =>
|
|
50
|
+
Math.max(WORKER_FLOOR, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
|