@ultimat3/cli 6.0.0 → 8.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
@@ -10,6 +10,8 @@
10
10
  // and `join` builds the host-separator path to its config file.
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
+ import type { TestType } from '@ultimat3/testing';
14
+ import { TEST_TYPES } from '@ultimat3/testing';
13
15
  import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
14
16
  import { APP_CONFIG_FILE } from './app-root';
15
17
  import { countsOf } from './test-counts';
@@ -20,9 +22,15 @@ import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
20
22
  import { fromExec, fromFindings } from './verify-step';
21
23
  import { runParallel } from './verify-test-run';
22
24
 
23
- export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const;
24
-
25
- export type TestType = (typeof TEST_TYPES)[number];
25
+ /**
26
+ * The six types are `@ultimat3/testing`'s declaration, not a list restated here: `unitTest`,
27
+ * `contractTest` and the rest prefix a test's reported name with one of these words, and this
28
+ * package selects a suite by the same word. A copy is one edit away from a step that discovers
29
+ * files no helper produces. `cli -> testing` is a declared sideways edge (`scripts/lib/tiers.ts`)
30
+ * and `@ultimat3/testing` is already a runtime dependency of this package.
31
+ */
32
+ export type { TestType } from '@ultimat3/testing';
33
+ export { TEST_TYPES } from '@ultimat3/testing';
26
34
 
27
35
  type TypedTest = Exclude<TestType, 'unit'>;
28
36
 
@@ -0,0 +1,241 @@
1
+ // The workspace dependency graph, in the two forms that must agree: the edges every manifest
2
+ // DECLARES, the edges every shipped source file actually IMPORTS, and the gate rule that the
3
+ // second is a subset of the first. An edge that lives only in a tsconfig `paths` entry resolves
4
+ // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
5
+
6
+ import { join } from 'node:path';
7
+ import type { Finding } from './output';
8
+ import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
9
+ import { maskLiterals } from './ts-scan';
10
+
11
+ export interface WorkspaceNode {
12
+ /** package.json "name". */
13
+ readonly name: string;
14
+ /** Directory, root-relative and POSIX. */
15
+ readonly dir: string;
16
+ /** Workspace package names this one declares, across every dependency field. */
17
+ readonly dependencies: readonly string[];
18
+ /**
19
+ * package.json "version", `0.0.0` when the manifest states none. Optional on the type and always
20
+ * present in what `readWorkspaceGraph` returns — the range a `fix:` tells an author to pin to has
21
+ * to be the one the workspace really carries, or the edit it names fails `checkLockstep` next.
22
+ */
23
+ readonly version?: string;
24
+ }
25
+
26
+ /**
27
+ * Every field npm installs from. `devDependencies` counts as a declaration here — it resolves the
28
+ * import exactly like `dependencies` does; only the tarball tells them apart, which is
29
+ * `checkPublishShape`'s question and not this one.
30
+ */
31
+ const DEPENDENCY_FIELDS = [
32
+ 'dependencies',
33
+ 'devDependencies',
34
+ 'peerDependencies',
35
+ 'optionalDependencies',
36
+ ] as const;
37
+
38
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
39
+ typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ ? (value as Record<string, unknown>)
41
+ : undefined;
42
+
43
+ const stringsOf = (value: unknown): readonly string[] =>
44
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
45
+
46
+ /**
47
+ * A manifest that will not parse, or will not open, is SKIPPED rather than thrown from. Both this
48
+ * rule and change detection read the whole graph in one pass, so one unparseable package.json
49
+ * anywhere in a monorepo would take the caller down with a `SyntaxError` naming no file — which is
50
+ * issue #281, open against `scripts/lib/workspaces.ts`, and not a defect worth having twice.
51
+ */
52
+ async function readManifest(path: string): Promise<Record<string, unknown> | undefined> {
53
+ try {
54
+ return asRecord(await Bun.file(path).json());
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** The `workspaces` globs a root manifest declares, in both spellings npm accepts. */
61
+ export function workspaceGlobs(manifest: unknown): readonly string[] {
62
+ const field = asRecord(manifest)?.['workspaces'];
63
+ return Array.isArray(field) ? stringsOf(field) : stringsOf(asRecord(field)?.['packages']);
64
+ }
65
+
66
+ const declaredDepsOf = (manifest: Record<string, unknown>): readonly string[] =>
67
+ DEPENDENCY_FIELDS.flatMap((field) => Object.keys(asRecord(manifest[field]) ?? {}));
68
+
69
+ export interface WorkspaceScan {
70
+ readonly nodes: readonly WorkspaceNode[];
71
+ /**
72
+ * Manifest paths the root's globs claim that do not parse, or name no package. Skipping one is
73
+ * what keeps a caller alive; reporting it is what keeps the skip from being a hiding place — a
74
+ * workspace absent from the graph is a workspace no rule here can see.
75
+ */
76
+ readonly unreadable: readonly string[];
77
+ }
78
+
79
+ /**
80
+ * Every workspace the root manifest claims, sorted by directory. Two workspaces answering to one
81
+ * name keep the first by that order rather than the first the glob happened to yield — a graph a
82
+ * second run disagrees with is worse than either answer.
83
+ */
84
+ export async function scanWorkspaces(root: string): Promise<WorkspaceScan> {
85
+ const rootManifest = await readManifest(join(root, 'package.json'));
86
+ const found: { dir: string; name: string; version: string; declared: readonly string[] }[] = [];
87
+ const unreadable: string[] = [];
88
+ for (const pattern of workspaceGlobs(rootManifest)) {
89
+ for await (const relative of new Bun.Glob(`${pattern}/package.json`).scan({
90
+ cwd: root,
91
+ absolute: false,
92
+ })) {
93
+ if (isVendored(relative)) continue;
94
+ const manifest = await readManifest(join(root, relative));
95
+ const name = manifest?.['name'];
96
+ if (manifest === undefined || typeof name !== 'string' || name === '') {
97
+ unreadable.push(relative.replaceAll('\\', '/'));
98
+ continue;
99
+ }
100
+ const version = manifest['version'];
101
+ found.push({
102
+ dir: relative.replaceAll('\\', '/').slice(0, -'/package.json'.length),
103
+ name,
104
+ version: typeof version === 'string' ? version : '0.0.0',
105
+ declared: declaredDepsOf(manifest),
106
+ });
107
+ }
108
+ }
109
+ found.sort((left, right) => left.dir.localeCompare(right.dir));
110
+ const names = new Set<string>();
111
+ const unique = found.filter((entry) => {
112
+ if (names.has(entry.name)) return false;
113
+ names.add(entry.name);
114
+ return true;
115
+ });
116
+ // Second pass, once every name is known: an edge is only an edge to another WORKSPACE. A
117
+ // dependency on a registry package is a fact about the lockfile, not about this repo's shape.
118
+ const nodes = unique.map(({ dir, name, version, declared }) => ({
119
+ dir,
120
+ name,
121
+ version,
122
+ dependencies: [...new Set(declared)].filter((dep) => dep !== name && names.has(dep)),
123
+ }));
124
+ return { nodes, unreadable: unreadable.sort() };
125
+ }
126
+
127
+ /** The graph alone, for every caller that has nothing to report about the manifests it skipped. */
128
+ export const readWorkspaceGraph = async (root: string): Promise<readonly WorkspaceNode[]> =>
129
+ (await scanWorkspaces(root)).nodes;
130
+
131
+ /**
132
+ * The package a bare specifier names — `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`. A
133
+ * relative, absolute or protocol specifier (`node:fs`, `bun:test`) names no package at all.
134
+ */
135
+ export function packageOfSpecifier(specifier: string): string | undefined {
136
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) {
137
+ return undefined;
138
+ }
139
+ const [first, second] = specifier.split('/');
140
+ if (first === undefined || first === '') return undefined;
141
+ if (!first.startsWith('@')) return first;
142
+ return second === undefined || second === '' ? undefined : `${first}/${second}`;
143
+ }
144
+
145
+ /**
146
+ * Every form that names a module: `… from '…'`, a bare `import '…'`, `import('…')`, `require('…')`.
147
+ * `from` is matched only where a quote follows it directly, so `from<Row>('posts', …)` — the query
148
+ * builder, which reads exactly like an import — cannot be one.
149
+ */
150
+ const IMPORT_FORM =
151
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|(?:^|[;{])\s*import\s+)['"]([^'"]*)['"]/g;
152
+
153
+ /**
154
+ * Every package a source file imports, deduplicated, in first-appearance order.
155
+ *
156
+ * Read from the MASKED text, because a generator's template literal holds whole programs: every
157
+ * `templates/*.ts` here emits `import … from '@ultimat3/ui'` as a string, and a scan that read
158
+ * those would bill the CLI for the imports of the app it writes. The line a masked hit falls on is
159
+ * then re-read from the real source — the specifier itself is what masking blanks.
160
+ */
161
+ export function importedPackages(source: string): readonly string[] {
162
+ const masked = maskLiterals(source).split('\n');
163
+ const lines = source.split('\n');
164
+ const packages = new Set<string>();
165
+ for (const [index, maskedLine] of masked.entries()) {
166
+ const line = lines[index];
167
+ // `matchAll` clones the regex, so the shared `lastIndex` is never carried between lines.
168
+ if (line === undefined || [...maskedLine.matchAll(IMPORT_FORM)].length === 0) continue;
169
+ for (const match of line.matchAll(IMPORT_FORM)) {
170
+ const name = packageOfSpecifier(match[1] ?? '');
171
+ if (name !== undefined) packages.add(name);
172
+ }
173
+ }
174
+ return [...packages];
175
+ }
176
+
177
+ /**
178
+ * Borrowed, not twinned: `X_APP_PACKAGE_INVALID` already means "this package.json supplies no
179
+ * usable name", and a workspace manifest is that same file one directory down. `bun pm pkg set`
180
+ * is not the fix here — it parses the file it edits, so it fails on exactly the input this
181
+ * reports.
182
+ */
183
+ export const unreadableWorkspaceFinding = (path: string): Finding => ({
184
+ code: 'X_APP_PACKAGE_INVALID',
185
+ cause: `${path} is claimed by the root "workspaces" globs and supplies no readable "name"`,
186
+ fix: `repair the JSON and the "name" in ${path}, or drop its directory from "workspaces" in package.json`,
187
+ docs: 'https://ultimate.dev/errors/X_APP_PACKAGE_INVALID',
188
+ at: path,
189
+ });
190
+
191
+ export const undeclaredWorkspaceDepFinding = (
192
+ from: WorkspaceNode,
193
+ to: WorkspaceNode,
194
+ at: string,
195
+ ): Finding => ({
196
+ code: 'X_WORKSPACE_DEP_UNDECLARED',
197
+ cause: `${at} imports ${to.name}, which ${from.dir}/package.json does not declare`,
198
+ // The exact line to paste, at the version the target really carries: a `workspace:*` range would
199
+ // resolve and then fail `checkLockstep`, which compares a sibling pin against the version.
200
+ fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "dependencies" in ${from.dir}/package.json`,
201
+ docs: 'https://ultimate.dev/errors/X_WORKSPACE_DEP_UNDECLARED',
202
+ at: `${from.dir}/package.json`,
203
+ });
204
+
205
+ /**
206
+ * The declared graph must cover the real one. `x new` wrote packages that import each other and
207
+ * declared none of it — the imports resolved through the root tsconfig's `paths`, so the edges
208
+ * existed only inside `tsc` and every tool that asks what a change affects answered short.
209
+ *
210
+ * Shipped source only. A test file is excluded from the tarball by every package here
211
+ * (`TEST_EXCLUSION`) and resolves its imports through the ROOT manifest's hoisted devDependencies,
212
+ * which is why no `packages/*` in this repo carries a `devDependencies` block of its own —
213
+ * judging test imports would demand 29 of them to describe a resolution that already works.
214
+ *
215
+ * One finding per (workspace, dependency) pair, never one per import site: the fix is one line in
216
+ * one manifest, and repeating it per file is noise an author has to deduplicate by hand.
217
+ */
218
+ export async function checkWorkspaceDependencies(root: string): Promise<readonly Finding[]> {
219
+ const { nodes: graph, unreadable } = await scanWorkspaces(root);
220
+ const findings: Finding[] = unreadable.map(unreadableWorkspaceFinding);
221
+ if (graph.length === 0) return findings;
222
+ const byName = new Map(graph.map((node) => [node.name, node]));
223
+ // Deepest directory first: a nested workspace's files are its own, never its parent's.
224
+ const owners = [...graph].sort((left, right) => right.dir.length - left.dir.length);
225
+ const reported = new Set<string>();
226
+ for await (const path of eachSourceFile(root)) {
227
+ if (isTest(path) || isGenerated(path)) continue;
228
+ const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
229
+ if (owner === undefined) continue;
230
+ for (const name of importedPackages(await Bun.file(join(root, path)).text())) {
231
+ const target = byName.get(name);
232
+ if (target === undefined || target.name === owner.name) continue;
233
+ if (owner.dependencies.includes(target.name)) continue;
234
+ const pair = `${owner.dir} ${target.name}`;
235
+ if (reported.has(pair)) continue;
236
+ reported.add(pair);
237
+ findings.push(undeclaredWorkspaceDepFinding(owner, target, path));
238
+ }
239
+ }
240
+ return findings;
241
+ }
package/src/write-line.ts CHANGED
@@ -1,6 +1,7 @@
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.
1
+ // The two writes every published entry point uses, one per fd — `packages/cli/src/bin.ts` and
2
+ // `create-ultimate`'s, the second of which shipped the `process.stdout.write` + `process.exit`
3
+ // pair the note below rules out. fd 2 exists because fd 1 is not always a log: under
4
+ // `x mcp serve --transport stdio` it is the protocol, under `--json` one document a caller parses.
4
5
 
5
6
  // `node:fs`, and unavoidable: Bun has no synchronous stdout write of its own.
6
7
  import { writeSync } from 'node:fs';
@@ -21,14 +22,31 @@ import { writeSync } from 'node:fs';
21
22
  * took the whole command down on a runner, emitting nothing at all. The reader drains in
22
23
  * microseconds; the retry is the correct response to "would block".
23
24
  */
24
- export function writeLine(line: string): void {
25
+ function writeTo(fd: 1 | 2, line: string): void {
25
26
  const buffer = Buffer.from(`${line}\n`);
26
27
  let written = 0;
27
28
  while (written < buffer.length) {
28
29
  try {
29
- written += writeSync(1, buffer, written, buffer.length - written);
30
+ written += writeSync(fd, buffer, written, buffer.length - written);
30
31
  } catch (cause) {
31
32
  if ((cause as NodeJS.ErrnoException).code !== 'EAGAIN') throw cause;
32
33
  }
33
34
  }
34
35
  }
36
+
37
+ export function writeLine(line: string): void {
38
+ writeTo(1, line);
39
+ }
40
+
41
+ /**
42
+ * The same write, on fd 2: for a line that is not the command's answer. `dispatch` sends a result
43
+ * here when it declares `stream: 'stderr'` — `x mcp serve --transport stdio`, whose stdout carries
44
+ * JSON-RPC frames and where a `✓ …` banner is a malformed one to whatever is reading.
45
+ *
46
+ * Every guarantee above is the same guarantee here, and that is the reason this is one loop and
47
+ * not two: fd 2 is a pipe under `2>` and in CI exactly as fd 1 is, so a second copy would be a
48
+ * second place for the truncation and the `EAGAIN` handling to drift apart.
49
+ */
50
+ export function writeErrorLine(line: string): void {
51
+ writeTo(2, line);
52
+ }