@ultimat3/cli 1.2.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 +83 -16
- 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 +165 -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 +201 -138
- 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 +84 -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 +4 -3
- 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 +170 -10
- 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/verify-tests.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// One
|
|
2
|
-
//
|
|
3
|
-
// `*.
|
|
4
|
-
//
|
|
1
|
+
// One gate step per test type, so every type reports on its own line. A test's type is its
|
|
2
|
+
// filename suffix — `*.contract.test.ts`, `*.live.test.ts`, `*.job.test.ts`, `*.e2e.test.ts`,
|
|
3
|
+
// `*.eval.test.ts` — and only then the `e2e/` directory it sits in, which is why `OWNERSHIP` below
|
|
4
|
+
// is an ordered list. Everything else is a unit test, the only step that selects by exclusion.
|
|
5
5
|
//
|
|
6
6
|
// `eval` carries one rule beyond its suite — every prompt must have an eval — so it is the only
|
|
7
7
|
// step here that can fail with no test file of its own.
|
|
@@ -12,67 +12,131 @@ import { existsSync } from 'node:fs';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
|
|
14
14
|
import { APP_CONFIG_FILE } from './app-root';
|
|
15
|
+
import { countsOf } from './test-counts';
|
|
16
|
+
import type { TestFile } from './test-select';
|
|
17
|
+
import { discoverTests } from './test-select';
|
|
18
|
+
import { defaultWorkers } from './test-workers';
|
|
15
19
|
import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
16
20
|
import { fromExec, fromFindings } from './verify-step';
|
|
21
|
+
import { runParallel } from './verify-test-run';
|
|
17
22
|
|
|
18
23
|
export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const;
|
|
19
24
|
|
|
20
25
|
export type TestType = (typeof TEST_TYPES)[number];
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
readonly summary: string;
|
|
24
|
-
/** Substring `bun test` matches against each file path. */
|
|
25
|
-
readonly filter: string;
|
|
26
|
-
/** Globs that decide whether this type exists here at all. */
|
|
27
|
-
readonly globs: readonly string[];
|
|
28
|
-
}
|
|
27
|
+
type TypedTest = Exclude<TestType, 'unit'>;
|
|
29
28
|
|
|
30
29
|
const TYPED_SUFFIXES = '{contract,live,job,e2e,eval}';
|
|
31
30
|
|
|
32
|
-
const
|
|
33
|
-
contract:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
live: {
|
|
39
|
-
summary: 'live-query snapshots, incremental patches, reconnect deltas',
|
|
40
|
-
filter: '.live.test.',
|
|
41
|
-
globs: ['**/*.live.test.{ts,tsx}'],
|
|
42
|
-
},
|
|
43
|
-
job: {
|
|
44
|
-
summary: 'step replay, idempotency dedupe, retry/backoff, outbox atomicity',
|
|
45
|
-
filter: '.job.test.',
|
|
46
|
-
globs: ['**/*.job.test.{ts,tsx}'],
|
|
47
|
-
},
|
|
48
|
-
e2e: {
|
|
49
|
-
summary: 'the built output, incl. offline and SW update',
|
|
50
|
-
filter: 'e2e',
|
|
51
|
-
globs: ['**/*.e2e.test.{ts,tsx}', '**/e2e/**/*.test.{ts,tsx}'],
|
|
52
|
-
},
|
|
53
|
-
eval: {
|
|
54
|
-
summary: 'LLM output scored against thresholds',
|
|
55
|
-
filter: '.eval.test.',
|
|
56
|
-
globs: ['**/*.eval.test.{ts,tsx}'],
|
|
57
|
-
},
|
|
31
|
+
const SUMMARIES: Readonly<Record<TypedTest, string>> = {
|
|
32
|
+
contract: 'action/query schemas, policy denials, emitted OpenAPI and MCP shapes',
|
|
33
|
+
live: 'live-query snapshots, incremental patches, reconnect deltas',
|
|
34
|
+
job: 'step replay, idempotency dedupe, retry/backoff, outbox atomicity',
|
|
35
|
+
e2e: 'the built output, incl. offline and SW update',
|
|
36
|
+
eval: 'LLM output scored against thresholds',
|
|
58
37
|
};
|
|
59
38
|
|
|
60
39
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
40
|
+
* Every rule that decides a file's type, MOST SPECIFIC FIRST: the first entry a path matches owns
|
|
41
|
+
* it, and no later entry may claim it. Each is a substring `bun test` matches against a file path,
|
|
42
|
+
* which is exactly how bun reads more than one positional filter (measured: `.contract.test.` +
|
|
43
|
+
* `.job.test.` runs the union of both suites, never the intersection).
|
|
44
|
+
*
|
|
45
|
+
* A FILENAME declares a type; a DIRECTORY only fills in for a filename that declares none — which
|
|
46
|
+
* is why the one directory rule is last. Ownership had no order at all until 2026-08, so
|
|
47
|
+
* `packages/app/e2e/payment.contract.test.ts` matched `.contract.test.` AND `e2e/`: the `contract`
|
|
48
|
+
* step selected it by name while the `e2e` step's argv selected it by directory, and one test ran
|
|
49
|
+
* twice in one gate. The bare word `e2e` was worse still — it matched any path holding those three
|
|
50
|
+
* characters anywhere, so `src/e2e-helpers.test.ts` joined the e2e step and left the unit step,
|
|
51
|
+
* which selects by exclusion.
|
|
52
|
+
*/
|
|
53
|
+
const OWNERSHIP = [
|
|
54
|
+
['contract', '.contract.test.'],
|
|
55
|
+
['live', '.live.test.'],
|
|
56
|
+
['job', '.job.test.'],
|
|
57
|
+
['e2e', '.e2e.test.'],
|
|
58
|
+
['eval', '.eval.test.'],
|
|
59
|
+
// `e2e/`, not `/e2e/`: bun matches a filter against the cwd-relative path and answers
|
|
60
|
+
// `Test filter "/e2e/" had no matches` for the anchored form (bun 1.3.14). The trailing slash
|
|
61
|
+
// is the boundary this can express, and it is the same string `ownerOf` ranks last, so the
|
|
62
|
+
// step's argv and the file list can never disagree about what an e2e test is.
|
|
63
|
+
['e2e', 'e2e/'],
|
|
64
|
+
] as const satisfies readonly (readonly [TypedTest, string])[];
|
|
65
|
+
|
|
66
|
+
/** The types whose filename rule outranks the `e2e/` directory — e2e's own never does. */
|
|
67
|
+
const OUTRANKING_E2E: readonly TypedTest[] = [
|
|
68
|
+
...new Set(OWNERSHIP.filter(([type]) => type !== 'e2e').map(([type]) => type)),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one suite a path belongs to, and `unit` when no rule claims it — the single definition of a
|
|
73
|
+
* file's type, which both `x test <type>` and the gate's own steps select through. Exactly one
|
|
74
|
+
* owner is the point: two owners is a file two steps run, and a test that runs twice in one gate
|
|
75
|
+
* proves nothing the first run did not.
|
|
76
|
+
*/
|
|
77
|
+
export const ownerOf = (path: string): TestType =>
|
|
78
|
+
OWNERSHIP.find(([, filter]) => path.includes(filter))?.[0] ?? 'unit';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Paths a suite's own filters match but the suite does NOT own, as `--path-ignore-patterns`. Only
|
|
82
|
+
* `e2e` has any, for the reason above. It has to be said in the argv and not only in `ownerOf`:
|
|
83
|
+
* bun has no "match this and not that" filter, and a serial step runs the argv rather than a file
|
|
84
|
+
* list — so exclusivity that lived only in the file list would still have run the file twice.
|
|
85
|
+
*/
|
|
86
|
+
const disownedBy = (type: TypedTest): readonly string[] =>
|
|
87
|
+
type === 'e2e' ? [`**/e2e/**/*.{${OUTRANKING_E2E.join(',')}}.test.*`] : [];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Which types run across worker processes, and why the other two cannot.
|
|
91
|
+
*
|
|
92
|
+
* Parallel is safe when the only thing a test file shares with another file is the database, and
|
|
93
|
+
* the database is per worker by construction (`ULTIMATE_TEST_WORKER` → one clone of the migrated
|
|
94
|
+
* template, `@ultimat3/testing`'s `acquireWorkerDatabase`). Every other process-global in this
|
|
95
|
+
* framework — the permission set, the roles, the entity/action/query registries, the error-code
|
|
96
|
+
* titles, the fixture bag — is handled by `--isolate` giving each FILE its own module registry.
|
|
97
|
+
*
|
|
98
|
+
* | Type | Why |
|
|
99
|
+
* |---|---|
|
|
100
|
+
* | `live` | **serial.** A logical replication slot and a publication are named at the Postgres
|
|
101
|
+
* CLUSTER level, not inside a database, and this repo's own feed tests hard-code
|
|
102
|
+
* `x_live_slot` / `x_live_pub` against `TEST_REPLICATION_URL` — the one server, never a per-worker
|
|
103
|
+
* clone. Two workers would race `pg_create_logical_replication_slot` and the loser's failure would
|
|
104
|
+
* read as a flake. A per-worker database does not isolate a cluster-wide object |
|
|
105
|
+
* | `e2e` | **serial.** It runs against the *built output*: one `dist/`, one service-worker
|
|
106
|
+
* registration, one browser profile. There is nothing per-worker to hand it, and the type is
|
|
107
|
+
* seconds at most, so a split would buy a race and no time |
|
|
108
|
+
*
|
|
109
|
+
* The two are named here rather than tested for, because "can this type be sharded?" is a design
|
|
110
|
+
* fact about the type, not something a run can discover about itself.
|
|
65
111
|
*/
|
|
66
|
-
const
|
|
112
|
+
export const SERIAL_TYPES: readonly TestType[] = ['live', 'e2e'];
|
|
113
|
+
|
|
114
|
+
const isSerial = (type: TestType): boolean => SERIAL_TYPES.includes(type);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build output, and nested projects that carry their own `x verify`. `examples/**` and
|
|
118
|
+
* `dummy/**` are the second kind: each holds a whole app gated by its own run of this same step
|
|
119
|
+
* list, so collecting them here would report one app failure on two different gates. The patterns
|
|
120
|
+
* are relative to the run's root, so this excludes nothing when the app itself is the root.
|
|
121
|
+
*
|
|
122
|
+
* `dummy/**` was added after the framework gate started running a demo app's tests and disagreeing
|
|
123
|
+
* with `bun run test` — which already excluded it — for no reason a reader could see.
|
|
124
|
+
*
|
|
125
|
+
* Excluded here is not ungated: `scripts/reference-app-gate.ts` runs `x verify` inside each of
|
|
126
|
+
* those apps and blocks on a per-app ratchet. Until 2026-08 that was only true of `examples/**`,
|
|
127
|
+
* and `dummy/social-media-clone` was excluded by this list and gated by nothing.
|
|
128
|
+
*/
|
|
129
|
+
const NEVER_A_TEST = ['**/dist/**', '**/build/**', '**/examples/**', '**/dummy/**'];
|
|
67
130
|
|
|
68
131
|
const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
|
|
69
132
|
patterns.map((pattern) => `--path-ignore-patterns=${pattern}`);
|
|
70
133
|
|
|
71
134
|
/**
|
|
72
|
-
* The
|
|
135
|
+
* The substrings that decide a file's type — the same ones the step's `bun test` runs with, so
|
|
73
136
|
* `x test <type>` and the gate's `<type>` step can never disagree about what a contract test is.
|
|
74
137
|
*/
|
|
75
|
-
export const
|
|
138
|
+
export const typeFiltersOf = (type: Exclude<TestType, 'unit'>): readonly string[] =>
|
|
139
|
+
OWNERSHIP.filter(([owner]) => owner === type).map(([, filter]) => filter);
|
|
76
140
|
|
|
77
141
|
/** Unit is everything the typed suites do not claim, so no test can fall between two steps. */
|
|
78
142
|
export const testStepCommand = (type: TestType): readonly string[] =>
|
|
@@ -82,34 +146,60 @@ export const testStepCommand = (type: TestType): readonly string[] =>
|
|
|
82
146
|
'test',
|
|
83
147
|
...ignoreFlags([...NEVER_A_TEST, '**/e2e/**', `**/*.${TYPED_SUFFIXES}.test.*`]),
|
|
84
148
|
]
|
|
85
|
-
: [
|
|
149
|
+
: [
|
|
150
|
+
'bun',
|
|
151
|
+
'test',
|
|
152
|
+
...ignoreFlags([...NEVER_A_TEST, ...disownedBy(type)]),
|
|
153
|
+
...typeFiltersOf(type),
|
|
154
|
+
];
|
|
86
155
|
|
|
87
156
|
/**
|
|
88
|
-
* Whether a step applies
|
|
89
|
-
*
|
|
90
|
-
*
|
|
157
|
+
* Whether a step applies and what it runs are now one question with one answer: the file list.
|
|
158
|
+
* They used to be two — a glob for `applies`, `--path-ignore-patterns` for the run — and when they
|
|
159
|
+
* drifted, a suite that lived only under an ignored path made its step apply and then fail with
|
|
160
|
+
* "no test files matched", a red gate reporting a suite that by its own rule is not here.
|
|
161
|
+
*
|
|
162
|
+
* Memoized because `runVerify` asks `applies` and then `run`, and a repeated walk of the whole
|
|
163
|
+
* tree per type is the cost this change exists to remove.
|
|
91
164
|
*/
|
|
92
|
-
const
|
|
165
|
+
const discovered = new Map<string, readonly TestFile[]>();
|
|
166
|
+
|
|
167
|
+
const filesFor = async (root: string, type: TestType): Promise<readonly TestFile[]> => {
|
|
168
|
+
const key = `${root}\u0000${type}`;
|
|
169
|
+
const cached = discovered.get(key);
|
|
170
|
+
if (cached !== undefined) return cached;
|
|
171
|
+
const files = await discoverTests(root, undefined, type);
|
|
172
|
+
discovered.set(key, files);
|
|
173
|
+
return files;
|
|
174
|
+
};
|
|
93
175
|
|
|
94
|
-
|
|
95
|
-
|
|
176
|
+
/** Test seam: a fixture that writes new test files under a root this process already scanned. */
|
|
177
|
+
export const resetTestDiscovery = (): void => discovered.clear();
|
|
96
178
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
|
|
180
|
+
const command = testStepCommand(type);
|
|
181
|
+
const result = await ctx.runner(command, { cwd: ctx.root });
|
|
182
|
+
return {
|
|
183
|
+
...fromExec(result, {
|
|
184
|
+
code: 'X_TEST_FAILED',
|
|
185
|
+
cause: `one or more ${type} tests failed`,
|
|
186
|
+
fix: command.join(' '),
|
|
187
|
+
}),
|
|
188
|
+
workers: 1,
|
|
189
|
+
tests: countsOf([result]),
|
|
190
|
+
};
|
|
104
191
|
};
|
|
105
192
|
|
|
106
193
|
const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
194
|
+
if (isSerial(type)) return runSerial(ctx, type);
|
|
195
|
+
const files = await filesFor(ctx.root, type);
|
|
196
|
+
if (files.length === 0) return runSerial(ctx, type);
|
|
197
|
+
return runParallel({
|
|
198
|
+
root: ctx.root,
|
|
199
|
+
runner: ctx.runner,
|
|
200
|
+
files,
|
|
201
|
+
workers: ctx.workers ?? defaultWorkers(),
|
|
202
|
+
type,
|
|
113
203
|
});
|
|
114
204
|
};
|
|
115
205
|
|
|
@@ -123,8 +213,8 @@ const isApp = (root: string): boolean => existsSync(join(root, APP_CONFIG_FILE))
|
|
|
123
213
|
*/
|
|
124
214
|
const evalStep: VerifyStep = {
|
|
125
215
|
name: 'eval',
|
|
126
|
-
summary:
|
|
127
|
-
applies: async (ctx) => isApp(ctx.root) || (await
|
|
216
|
+
summary: SUMMARIES.eval,
|
|
217
|
+
applies: async (ctx) => isApp(ctx.root) || (await filesFor(ctx.root, 'eval')).length > 0,
|
|
128
218
|
async run(ctx) {
|
|
129
219
|
// First, and instead of the suite: under recording every eval writes the numbers it just
|
|
130
220
|
// measured and passes, so running it here would rewrite the committed baselines during the
|
|
@@ -134,12 +224,12 @@ const evalStep: VerifyStep = {
|
|
|
134
224
|
const declarations = isApp(ctx.root)
|
|
135
225
|
? [...(await checkEvalCoverage(ctx.root)), ...(await checkEvalBaselines(ctx.root))]
|
|
136
226
|
: [];
|
|
137
|
-
if (
|
|
227
|
+
if ((await filesFor(ctx.root, 'eval')).length === 0) return fromFindings(declarations);
|
|
138
228
|
const suite = await runType(ctx, 'eval');
|
|
139
229
|
return {
|
|
230
|
+
...suite,
|
|
140
231
|
ok: suite.ok && declarations.length === 0,
|
|
141
232
|
findings: [...declarations, ...suite.findings],
|
|
142
|
-
...(suite.output === undefined ? {} : { output: suite.output }),
|
|
143
233
|
};
|
|
144
234
|
},
|
|
145
235
|
};
|
|
@@ -153,11 +243,10 @@ const stepFor = (type: TestType): VerifyStep => {
|
|
|
153
243
|
};
|
|
154
244
|
}
|
|
155
245
|
if (type === 'eval') return evalStep;
|
|
156
|
-
const suite = SUITES[type];
|
|
157
246
|
return {
|
|
158
247
|
name: type,
|
|
159
|
-
summary:
|
|
160
|
-
applies: (ctx) =>
|
|
248
|
+
summary: SUMMARIES[type],
|
|
249
|
+
applies: async (ctx) => (await filesFor(ctx.root, type)).length > 0,
|
|
161
250
|
run: (ctx) => runType(ctx, type),
|
|
162
251
|
};
|
|
163
252
|
};
|
package/src/version-loader.ts
CHANGED
|
@@ -5,12 +5,29 @@
|
|
|
5
5
|
// Bun has no path-join primitive; `import.meta.dir` is this module's directory in both the
|
|
6
6
|
// checked-out `src/` layout and the published `dist/` one, each one level below the package root.
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveVersion } from '@ultimat3/core';
|
|
9
9
|
|
|
10
10
|
/** `@ultimat3/cli`'s own manifest — released in lockstep with the rest of `@ultimat3/*`. */
|
|
11
11
|
export const CLI_MANIFEST = resolve(import.meta.dir, '..', 'package.json');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Replaced with a string literal by `bun build --define ULTIMATE_FRAMEWORK_VERSION='"1.2.3"'`
|
|
14
|
+
// (`binaryArgs` in `cmd-build.ts`), and declared by nothing at runtime — hence the `typeof` guard
|
|
15
|
+
// below. An unbundled process must see `undefined` here, not a `ReferenceError`.
|
|
16
|
+
declare const ULTIMATE_FRAMEWORK_VERSION: string | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manifest first, build define second — the same fallback `frameworkVersion()` takes, through the
|
|
20
|
+
* same `resolveVersion`, off deliberately the same define. A compiled single-file executable
|
|
21
|
+
* carries no `package.json`, so without the fallback `x --version` inside one answers `X_INVARIANT`
|
|
22
|
+
* for a version the build already knew; and a second define name would be a second version fact to
|
|
23
|
+
* hold in step, when `@ultimat3/cli` and `@ultimat3/core` ship one version, one commit, one tag.
|
|
24
|
+
*
|
|
25
|
+
* Throws `X_INVARIANT` if neither source answers — a publish with no version, or a `--compile` that
|
|
26
|
+
* skipped the define. See `resolveVersion`.
|
|
27
|
+
*/
|
|
14
28
|
export function loadVersion(): string {
|
|
15
|
-
return
|
|
29
|
+
return resolveVersion(
|
|
30
|
+
CLI_MANIFEST,
|
|
31
|
+
typeof ULTIMATE_FRAMEWORK_VERSION === 'string' ? ULTIMATE_FRAMEWORK_VERSION : undefined,
|
|
32
|
+
);
|
|
16
33
|
}
|
package/src/workspace-checks.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
// package shipping the same contract files,
|
|
3
|
-
//
|
|
4
|
-
// rule (axiom 3).
|
|
1
|
+
// Four shape rules the gate owns: one file, one job (a hard line ceiling), every workspace
|
|
2
|
+
// package shipping the same contract files, every published package's tarball matching what its
|
|
3
|
+
// manifest promises, and every published package being in the root build graph. All report
|
|
4
|
+
// findings — a shape rule that is only written down is not a rule (axiom 3).
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
8
9
|
import type { Finding } from './output';
|
|
9
|
-
import { eachSourceFile } from './source-files';
|
|
10
|
+
import { eachSourceFile, isGenerated } from './source-files';
|
|
11
|
+
import { checkRootReferences } from './tsconfig-references';
|
|
10
12
|
|
|
11
13
|
export const LINE_CEILING = 500;
|
|
12
14
|
|
|
@@ -34,6 +36,7 @@ export const countLines = (text: string): number =>
|
|
|
34
36
|
export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
|
|
35
37
|
const findings: Finding[] = [];
|
|
36
38
|
for await (const path of eachSourceFile(root)) {
|
|
39
|
+
if (isGenerated(path)) continue;
|
|
37
40
|
const lines = countLines(await Bun.file(join(root, path)).text());
|
|
38
41
|
if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
|
|
39
42
|
}
|
|
@@ -52,7 +55,7 @@ export const missingFileFinding = (dir: string, file: string, scaffolder: boolea
|
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
57
|
* A published package reports its own version by reading its own `package.json` at runtime
|
|
55
|
-
* (`@ultimat3/core`'s `
|
|
58
|
+
* (`@ultimat3/core`'s `frameworkVersion()`, the CLI's `loadVersion()`, every dependency `x new` pins).
|
|
56
59
|
* A manifest with no semver `version` therefore breaks the MCP handshake and every scaffold — so
|
|
57
60
|
* the gate refuses the publish here, where the fix is one line, rather than at someone's install.
|
|
58
61
|
*/
|
|
@@ -60,7 +63,10 @@ export const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
|
|
|
60
63
|
|
|
61
64
|
export const badVersionFinding = (dir: string, found: unknown): Finding => ({
|
|
62
65
|
code: 'X_PACKAGE_SHAPE',
|
|
63
|
-
|
|
66
|
+
// `found` is whatever the manifest's `version` parsed to — an app's JSON, not the gate's value.
|
|
67
|
+
// Bare `JSON.stringify` throws on a bigint and a cycle and runs any `toJSON`, which would replace
|
|
68
|
+
// this finding with a TypeError from inside the gate.
|
|
69
|
+
cause: `packages/${dir}/package.json has no semver "version" (found ${renderCauseValue(found)})`,
|
|
64
70
|
fix: `set a semver "version" in packages/${dir}/package.json, then: bun run verify`,
|
|
65
71
|
docs: docs('X_PACKAGE_SHAPE'),
|
|
66
72
|
at: `packages/${dir}/package.json`,
|
|
@@ -124,19 +130,27 @@ export const pinSkewFinding = (
|
|
|
124
130
|
* Private packages are exempt on both counts: a generated app's `packages/*` are private, carry
|
|
125
131
|
* their own version line and depend on the framework by caret range.
|
|
126
132
|
*/
|
|
127
|
-
export function checkLockstep(
|
|
133
|
+
export function checkLockstep(
|
|
134
|
+
manifests: readonly ManifestFacts[],
|
|
135
|
+
release?: string,
|
|
136
|
+
): readonly Finding[] {
|
|
128
137
|
const published = manifests.filter((manifest) => !manifest.private);
|
|
129
138
|
// `core` is tier 0 and everything depends on it, so it is the version the rest must match.
|
|
130
|
-
const
|
|
131
|
-
|
|
139
|
+
const internal = published.find((manifest) => manifest.dir === 'core') ?? published[0];
|
|
140
|
+
// With a release version the anchor is EXTERNAL, and that is the whole point: comparing packages
|
|
141
|
+
// only to each other is green in a repo where all 29 sit at 1.2.0 and nine tags (v1.3.0 …
|
|
142
|
+
// v1.10.1) have been cut with no bump — a publish that dies `EPUBLISHCONFLICT` on all 29 while
|
|
143
|
+
// the gate says shippable. There is no anchor to the version being released unless one is given.
|
|
144
|
+
const version = release ?? internal?.version;
|
|
145
|
+
if (version === undefined) return [];
|
|
132
146
|
const findings: Finding[] = [];
|
|
133
147
|
for (const manifest of published) {
|
|
134
|
-
if (manifest.version !==
|
|
135
|
-
findings.push(versionSkewFinding(manifest.dir, manifest.version,
|
|
148
|
+
if (manifest.version !== version) {
|
|
149
|
+
findings.push(versionSkewFinding(manifest.dir, manifest.version, version));
|
|
136
150
|
}
|
|
137
151
|
for (const [dep, range] of manifest.frameworkDeps) {
|
|
138
|
-
if (range !==
|
|
139
|
-
findings.push(pinSkewFinding(manifest.dir, dep, range,
|
|
152
|
+
if (range !== version) {
|
|
153
|
+
findings.push(pinSkewFinding(manifest.dir, dep, range, version));
|
|
140
154
|
}
|
|
141
155
|
}
|
|
142
156
|
}
|
|
@@ -165,6 +179,27 @@ export const publishesTestsFinding = (dir: string): Finding => ({
|
|
|
165
179
|
at: `packages/${dir}/package.json`,
|
|
166
180
|
});
|
|
167
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Emit that is authored source anyway, so the sweep may not take it. One list, read by the check
|
|
184
|
+
* and written into the `fix:` that performs it — a second spelling is a command that deletes a file
|
|
185
|
+
* the gate then reports as missing.
|
|
186
|
+
*/
|
|
187
|
+
export const ARTIFACT_ALLOWLIST: readonly string[] = ['packages/ui/src/scss.d.ts'];
|
|
188
|
+
|
|
189
|
+
/** `-name` matches the same suffixes the check globs; `! -path` spares each allowlisted file. */
|
|
190
|
+
const SWEEP_PREDICATE = [
|
|
191
|
+
`\\( -name '*.d.ts' -o -name '*.js' -o -name '*.map' \\)`,
|
|
192
|
+
...ARTIFACT_ALLOWLIST.map((path) => `! -path '${path}'`),
|
|
193
|
+
].join(' ');
|
|
194
|
+
|
|
195
|
+
export const buildArtifactsFinding = (dir: string, count: number): Finding => ({
|
|
196
|
+
code: 'X_PACKAGE_SHAPE',
|
|
197
|
+
cause: `packages/${dir}/src/ contains ${count} build artifacts (.d.ts, .js, .map files)`,
|
|
198
|
+
fix: `find packages/${dir}/src ${SWEEP_PREDICATE} -delete`,
|
|
199
|
+
docs: docs('X_PACKAGE_SHAPE'),
|
|
200
|
+
at: `packages/${dir}/src/`,
|
|
201
|
+
});
|
|
202
|
+
|
|
168
203
|
/**
|
|
169
204
|
* Reported apart from the exclusion above so the `fix:` stays runnable. Told to "add an entry to
|
|
170
205
|
* `files`" when there is no `files` at all, an author edits a key that is not there — and axiom 4
|
|
@@ -254,16 +289,40 @@ export async function workspacePackages(root: string): Promise<readonly string[]
|
|
|
254
289
|
export const hasWorkspacePackages = async (root: string): Promise<boolean> =>
|
|
255
290
|
(await workspacePackages(root)).length > 0;
|
|
256
291
|
|
|
292
|
+
export interface PackageShapeOptions {
|
|
293
|
+
/**
|
|
294
|
+
* The version being released. Anchors the lockstep rule to it instead of to whatever the
|
|
295
|
+
* packages happen to agree on, which is what lets `scripts/release.ts --check <version>` and the
|
|
296
|
+
* release workflow ask "is this repo actually at the version this tag claims?" before publishing.
|
|
297
|
+
*/
|
|
298
|
+
readonly release?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
257
301
|
/** Every package ships the same contract files; a missing one is a build error, not a chore. */
|
|
258
|
-
export async function checkPackageShape(
|
|
302
|
+
export async function checkPackageShape(
|
|
303
|
+
root: string,
|
|
304
|
+
options: PackageShapeOptions = {},
|
|
305
|
+
): Promise<readonly Finding[]> {
|
|
259
306
|
const scaffolder = existsSync(join(root, 'scripts', 'new-package.ts'));
|
|
260
307
|
const findings: Finding[] = [];
|
|
261
308
|
const facts: ManifestFacts[] = [];
|
|
309
|
+
const allowlisted = new Set(ARTIFACT_ALLOWLIST);
|
|
262
310
|
for (const dir of await workspacePackages(root)) {
|
|
263
311
|
for (const file of PACKAGE_FILES) {
|
|
264
312
|
if (existsSync(join(root, 'packages', dir, file))) continue;
|
|
265
313
|
findings.push(missingFileFinding(dir, file, scaffolder));
|
|
266
314
|
}
|
|
315
|
+
// `src/` is authored source only; anything a build emitted there ships in the tarball too.
|
|
316
|
+
const artifacts: string[] = [];
|
|
317
|
+
for await (const path of new Bun.Glob('src/**/*.{d.ts,js,map}').scan({
|
|
318
|
+
cwd: join(root, 'packages', dir),
|
|
319
|
+
absolute: false,
|
|
320
|
+
})) {
|
|
321
|
+
if (!allowlisted.has(join('packages', dir, path))) artifacts.push(path);
|
|
322
|
+
}
|
|
323
|
+
if (artifacts.length > 0) {
|
|
324
|
+
findings.push(buildArtifactsFinding(dir, artifacts.length));
|
|
325
|
+
}
|
|
267
326
|
const manifest: unknown = await Bun.file(join(root, 'packages', dir, 'package.json')).json();
|
|
268
327
|
const record = (typeof manifest === 'object' && manifest !== null ? manifest : {}) as {
|
|
269
328
|
name?: unknown;
|
|
@@ -284,5 +343,17 @@ export async function checkPackageShape(root: string): Promise<readonly Finding[
|
|
|
284
343
|
files: filesOf(manifest),
|
|
285
344
|
});
|
|
286
345
|
}
|
|
287
|
-
return [
|
|
346
|
+
return [
|
|
347
|
+
...findings,
|
|
348
|
+
...checkLockstep(facts, options.release),
|
|
349
|
+
...checkPublishShape(root, facts),
|
|
350
|
+
// Nothing enforced that a workspace joins the root build graph, and `scripts/new-package.ts`
|
|
351
|
+
// never added one — so a package could ship, be imported, and be typechecked by nothing. It
|
|
352
|
+
// rides on this step because it is this step's own question: what does a workspace owe the
|
|
353
|
+
// repo it lives in?
|
|
354
|
+
...(await checkRootReferences(
|
|
355
|
+
root,
|
|
356
|
+
facts.filter((fact) => !fact.private).map((fact) => fact.dir),
|
|
357
|
+
)),
|
|
358
|
+
];
|
|
288
359
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The one stdout write every published entry point uses. Its own module because there are two of
|
|
2
|
+
// them — `packages/cli/src/bin.ts` and `create-ultimate`'s — and the second shipped
|
|
3
|
+
// `process.stdout.write` + `process.exit`, the exact pair the note below exists to rule out.
|
|
4
|
+
|
|
5
|
+
// `node:fs`, and unavoidable: Bun has no synchronous stdout write of its own.
|
|
6
|
+
import { writeSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Write to stdout and be certain it arrived, even if the next statement exits the process.
|
|
10
|
+
*
|
|
11
|
+
* `process.stdout.write()` is ASYNCHRONOUS whenever stdout is a pipe — which is what it is in CI
|
|
12
|
+
* and under `| jq`. Anything past the 64KB pipe buffer is queued, and `process.exit()` discards the
|
|
13
|
+
* queue, so the output silently truncates. A `--json` contract that truncates under a pipe is a
|
|
14
|
+
* `--json` contract for nobody: the pipe is the only reason it exists.
|
|
15
|
+
*
|
|
16
|
+
* The loop is not decoration — one `writeSync` to a pipe may write fewer bytes than it was handed,
|
|
17
|
+
* and dropping the remainder reintroduces the bug in a harder-to-see form.
|
|
18
|
+
*
|
|
19
|
+
* `EAGAIN` is "the pipe is full right now", not a failure. CI hands the process a NON-BLOCKING
|
|
20
|
+
* stdout, where `writeSync` throws rather than blocking — so the loop that fixed the truncation
|
|
21
|
+
* took the whole command down on a runner, emitting nothing at all. The reader drains in
|
|
22
|
+
* microseconds; the retry is the correct response to "would block".
|
|
23
|
+
*/
|
|
24
|
+
export function writeLine(line: string): void {
|
|
25
|
+
const buffer = Buffer.from(`${line}\n`);
|
|
26
|
+
let written = 0;
|
|
27
|
+
while (written < buffer.length) {
|
|
28
|
+
try {
|
|
29
|
+
written += writeSync(1, buffer, written, buffer.length - written);
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
if ((cause as NodeJS.ErrnoException).code !== 'EAGAIN') throw cause;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|