@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.
Files changed (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -1,7 +1,7 @@
1
- // One `bun test` invocation per test type, so every type reports on its own line of the gate. A
2
- // test's type is its filename suffix — `*.contract.test.ts`, `*.live.test.ts`, `*.job.test.ts`,
3
- // `*.e2e.test.ts` (or any file under an `e2e/` directory), `*.eval.test.ts`. Everything else is a
4
- // unit test, which is why the unit step is the only one that selects by exclusion.
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
- interface TestSuite {
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 SUITES: Readonly<Record<Exclude<TestType, 'unit'>, TestSuite>> = {
33
- contract: {
34
- summary: 'action/query schemas, policy denials, emitted OpenAPI and MCP shapes',
35
- filter: '.contract.test.',
36
- globs: ['**/*.contract.test.{ts,tsx}'],
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
- * Build output, and nested projects that carry their own `x verify`. `examples/**` is the second
62
- * kind: the reference app is gated by its own run of this same step list, so collecting it here
63
- * would report one app failure on two different gates. The patterns are relative to the run's
64
- * root, so this excludes nothing when the app itself is the root.
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 NEVER_A_TEST = ['**/dist/**', '**/build/**', '**/examples/**'];
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 substring that decides a file's type — the same one the step's `bun test` runs with, so
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 typeFilterOf = (type: Exclude<TestType, 'unit'>): string => SUITES[type].filter;
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
- : ['bun', 'test', ...ignoreFlags(NEVER_A_TEST), SUITES[type].filter];
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 has to be decided by the same rule that decides what it runs. When the
89
- * two drifted, a suite that lived only under an ignored path made its step apply and then fail
90
- * with "no test files matched" — a red gate reporting a suite that, by its own rule, is not here.
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 NEVER_A_TEST_GLOBS = NEVER_A_TEST.map((pattern) => new Bun.Glob(pattern));
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
- const ignoredPath = (path: string): boolean =>
95
- path.includes('node_modules') || NEVER_A_TEST_GLOBS.some((glob) => glob.match(path));
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 exists = async (root: string, globs: readonly string[]): Promise<boolean> => {
98
- for (const pattern of globs) {
99
- for await (const path of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
100
- if (!ignoredPath(path)) return true;
101
- }
102
- }
103
- return false;
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
- const command = testStepCommand(type);
108
- const result = await ctx.runner(command, { cwd: ctx.root });
109
- return fromExec(result, {
110
- code: 'X_TEST_FAILED',
111
- cause: `one or more ${type} tests failed`,
112
- fix: command.join(' '),
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: SUITES.eval.summary,
127
- applies: async (ctx) => isApp(ctx.root) || (await exists(ctx.root, SUITES.eval.globs)),
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 (!(await exists(ctx.root, SUITES.eval.globs))) return fromFindings(declarations);
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: suite.summary,
160
- applies: (ctx) => exists(ctx.root, suite.globs),
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
  };
@@ -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 { readPackageVersion } from '@ultimat3/core';
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
- /** Throws `X_INVARIANT` if this package shipped without a version — see `readPackageVersion`. */
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 readPackageVersion(CLI_MANIFEST);
29
+ return resolveVersion(
30
+ CLI_MANIFEST,
31
+ typeof ULTIMATE_FRAMEWORK_VERSION === 'string' ? ULTIMATE_FRAMEWORK_VERSION : undefined,
32
+ );
16
33
  }
@@ -1,12 +1,14 @@
1
- // Three shape rules the gate owns: one file, one job (a hard line ceiling), every workspace
2
- // package shipping the same contract files, and every published package's tarball matching what
3
- // its manifest promises. All report findings — a shape rule that is only written down is not a
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 `FRAMEWORK_VERSION`, the CLI's `CLI_VERSION`, every dependency `x new` pins).
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
- cause: `packages/${dir}/package.json has no semver "version" (found ${JSON.stringify(found)})`,
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(manifests: readonly ManifestFacts[]): readonly Finding[] {
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 anchor = published.find((manifest) => manifest.dir === 'core') ?? published[0];
131
- if (anchor === undefined) return [];
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 !== anchor.version) {
135
- findings.push(versionSkewFinding(manifest.dir, manifest.version, anchor.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 !== anchor.version) {
139
- findings.push(pinSkewFinding(manifest.dir, dep, range, anchor.version));
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(root: string): Promise<readonly Finding[]> {
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 [...findings, ...checkLockstep(facts), ...checkPublishShape(root, facts)];
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
+ }