@ultimat3/cli 1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,137 @@
1
+ // `x.manifest.json`: the framework's registries projected by `@ultimat3/manifest`. The CLI
2
+ // supplies the facts that package cannot reach for itself — the app's name and version, the route
3
+ // table, which surface enforces each permission, the locales the app registered, and the error
4
+ // codes its source declares.
5
+
6
+ // Bun ships no `Bun.*` path API: `join` builds the host-separator path to `x.manifest.json`.
7
+ import { join } from 'node:path';
8
+ import { describeActions } from '@ultimat3/action';
9
+ import { registeredLocales } from '@ultimat3/i18n';
10
+ import { registeredTasks } from '@ultimat3/jobs';
11
+ import type { Manifest, PolicyFact, RouteFact, TaskFact } from '@ultimat3/manifest';
12
+ import {
13
+ buildManifest,
14
+ emitManifest,
15
+ frameworkSources,
16
+ MANIFEST_FILENAME,
17
+ readManifest,
18
+ } from '@ultimat3/manifest';
19
+ import { knownPermissions } from '@ultimat3/policy';
20
+ import { describeQueries } from '@ultimat3/query';
21
+ import type { RouteDescriptor } from '@ultimat3/render';
22
+ import { describeRoutes } from '@ultimat3/render';
23
+ import { loadApp } from './app-load';
24
+ import { AppPackageInvalidError } from './errors';
25
+ import type { Finding } from './output';
26
+
27
+ export interface AppManifest {
28
+ readonly manifest: Manifest;
29
+ /** Modules that would not load or register. The manifest describes what did. */
30
+ readonly findings: readonly Finding[];
31
+ }
32
+
33
+ /** Load the app, then describe it. Every command that needs facts goes through here. */
34
+ export async function appManifest(root: string): Promise<AppManifest> {
35
+ const loaded = await loadApp(root);
36
+ const manifest = buildManifest(
37
+ frameworkSources({
38
+ app: await appIdentity(root),
39
+ routes: routeFacts(),
40
+ policies: policyFacts(),
41
+ tasks: taskFacts(),
42
+ // The i18n registry, never a scan of `packages/i18n/catalogs/`: `loadApp` above has already
43
+ // imported every app module, so each `defineCatalogs()` has run and registered its locales.
44
+ // Counting catalog files instead would be a second answer, wrong the day the two disagree.
45
+ locales: registeredLocales(),
46
+ errorCodes: loaded.errorCodes,
47
+ }),
48
+ );
49
+ return { manifest, findings: loaded.findings };
50
+ }
51
+
52
+ export async function writeAppManifest(root: string, manifest: Manifest): Promise<string> {
53
+ const { path } = await emitManifest({ manifest, path: join(root, MANIFEST_FILENAME) });
54
+ return path;
55
+ }
56
+
57
+ /** The committed contract, or undefined when the app has never generated one. */
58
+ export const readAppManifest = async (root: string): Promise<Manifest | undefined> =>
59
+ readManifest(join(root, MANIFEST_FILENAME));
60
+
61
+ const jsonObject = async (file: Bun.BunFile): Promise<Record<string, unknown> | undefined> => {
62
+ const parsed: unknown = await file.json().catch(() => undefined);
63
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
64
+ ? (parsed as Record<string, unknown>)
65
+ : undefined;
66
+ };
67
+
68
+ /**
69
+ * Name and version come from `package.json`: the manifest's version gate is a semver gate, so a
70
+ * default of `app@0.0.0` would silently overwrite the compatibility contract with an identity the
71
+ * app never claimed. Every way of not knowing fails here instead.
72
+ */
73
+ async function appIdentity(root: string): Promise<{ name: string; version: string }> {
74
+ const path = join(root, 'package.json');
75
+ const file = Bun.file(path);
76
+ if (!(await file.exists())) throw new AppPackageInvalidError({ path, problem: 'does not exist' });
77
+ const parsed = await jsonObject(file);
78
+ if (parsed === undefined)
79
+ throw new AppPackageInvalidError({ path, problem: 'is not a JSON object' });
80
+ const { name, version } = parsed;
81
+ if (typeof name !== 'string')
82
+ throw new AppPackageInvalidError({ path, problem: 'has no string "name"' });
83
+ if (typeof version !== 'string')
84
+ throw new AppPackageInvalidError({ path, problem: 'has no string "version"' });
85
+ return { name, version };
86
+ }
87
+
88
+ type UrlRoute = RouteDescriptor & { readonly surface: 'site' | 'app' | 'api' };
89
+
90
+ /** `shared/` is a leaf, never a URL — a descriptor there is a file naming mistake, not a route. */
91
+ const hasUrl = (route: RouteDescriptor): route is UrlRoute => route.surface !== 'shared';
92
+
93
+ const routeFacts = (): readonly RouteFact[] =>
94
+ describeRoutes()
95
+ .filter(hasUrl)
96
+ .map((route) => ({
97
+ url: route.path,
98
+ render: route.mode,
99
+ offline: route.offline,
100
+ hydrate: route.hydrate,
101
+ revalidateTags: route.revalidateTags,
102
+ surface: route.surface,
103
+ ...budgetOf(route),
104
+ }));
105
+
106
+ function budgetOf(route: RouteDescriptor): { budget?: { js?: string; lcp?: number } } {
107
+ if (route.budgetJs === null && route.budgetLcp === null) return {};
108
+ return {
109
+ budget: {
110
+ ...(route.budgetJs === null ? {} : { js: route.budgetJs }),
111
+ ...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * One permission, N surfaces — `enforcedIn` is that list, derived from the actions and queries
118
+ * that actually assert it rather than declared a second time next to the policy.
119
+ */
120
+ export function policyFacts(): readonly PolicyFact[] {
121
+ const enforced = new Map<string, string[]>();
122
+ const add = (permission: string, where: string): void => {
123
+ if (permission.length === 0) return;
124
+ enforced.set(permission, [...(enforced.get(permission) ?? []), where]);
125
+ };
126
+ for (const action of describeActions()) add(action.capability, `action:${action.name}`);
127
+ for (const query of describeQueries()) add(query.capability, `query:${query.name}`);
128
+
129
+ return [...new Set([...knownPermissions(), ...enforced.keys()])]
130
+ .sort()
131
+ .map((permission) => ({ permission, enforcedIn: enforced.get(permission) ?? [] }));
132
+ }
133
+
134
+ const taskFacts = (): readonly TaskFact[] =>
135
+ registeredTasks()
136
+ .map((handle) => handle.describe())
137
+ .map((task) => ({ name: task.name, cron: task.cron, tz: task.tz, enqueues: task.jobs }));
@@ -0,0 +1,12 @@
1
+ // `openapi.json`, projected from the action registry by `@ultimat3/action`. The CLI writes the
2
+ // file and compares the bytes; it does not know how an operation is shaped, which is why there
3
+ // is no second OpenAPI builder to drift from the one the server serves.
4
+
5
+ import { buildOpenApi, serializeOpenApi } from '@ultimat3/action';
6
+ import type { Manifest } from '@ultimat3/manifest';
7
+
8
+ export const OPENAPI_FILE = 'openapi.json';
9
+
10
+ /** The exact bytes on disk — deterministic, so `x verify` can compare them literally. */
11
+ export const openApiJson = (manifest: Manifest): string =>
12
+ serializeOpenApi(buildOpenApi({ title: manifest.app.name, version: manifest.app.version }));
@@ -0,0 +1,57 @@
1
+ // Locating the app. `app.config.ts` is the one config file, so it is also the one root marker —
2
+ // commands that need an app resolve it here and nowhere else, and the failure names the fix.
3
+
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { BunVersionError, NotInAppError } from './errors';
7
+
8
+ export const APP_CONFIG_FILE = 'app.config.ts';
9
+ export const MANIFEST_FILE = 'x.manifest.json';
10
+ export const REQUIRED_BUN = '1.3.0';
11
+
12
+ export interface AppRoot {
13
+ readonly dir: string;
14
+ readonly configPath: string;
15
+ readonly manifestPath: string;
16
+ }
17
+
18
+ /** Walk up from `from` looking for `app.config.ts`. Returns undefined outside an app. */
19
+ export function findAppRoot(from: string): AppRoot | undefined {
20
+ let dir = resolve(from);
21
+ for (;;) {
22
+ const configPath = join(dir, APP_CONFIG_FILE);
23
+ if (existsSync(configPath)) {
24
+ return { dir, configPath, manifestPath: join(dir, MANIFEST_FILE) };
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir) return undefined;
28
+ dir = parent;
29
+ }
30
+ }
31
+
32
+ export function requireAppRoot(command: string, from: string): AppRoot {
33
+ const root = findAppRoot(from);
34
+ if (root === undefined) throw new NotInAppError({ command, from: resolve(from) });
35
+ return root;
36
+ }
37
+
38
+ /** Semver-lite compare, sufficient because both sides are plain `major.minor.patch`. */
39
+ export function versionAtLeast(found: string, required: string): boolean {
40
+ const parse = (input: string): readonly number[] =>
41
+ input
42
+ .split('-')[0]
43
+ ?.split('.')
44
+ .map((part) => Number.parseInt(part, 10) || 0) ?? [];
45
+ const a = parse(found);
46
+ const b = parse(required);
47
+ for (let i = 0; i < 3; i += 1) {
48
+ const left = a[i] ?? 0;
49
+ const right = b[i] ?? 0;
50
+ if (left !== right) return left > right;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ export function requireBunVersion(found: string, required: string = REQUIRED_BUN): void {
56
+ if (!versionAtLeast(found, required)) throw new BunVersionError({ found, required });
57
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bun
2
+ // The `x` entrypoint. Nothing lives here but argv, stdout and the exit code — every decision is in
3
+ // dispatch.ts, so the whole CLI is testable without spawning a process.
4
+
5
+ import { dispatch } from './dispatch';
6
+
7
+ const code = await dispatch({
8
+ argv: Bun.argv.slice(2),
9
+ cwd: process.cwd(),
10
+ env: Bun.env,
11
+ bunVersion: Bun.version,
12
+ write: (line) => {
13
+ process.stdout.write(`${line}\n`);
14
+ },
15
+ });
16
+
17
+ process.exit(code);
@@ -0,0 +1,219 @@
1
+ // The pure planner behind `x fix boundary`: turns every `BoundaryViolation` chain that touches a
2
+ // target file into a printable cut — the one edge to delete — and, when relocating a `shared/`
3
+ // module is what removes that edge, the `git mv` plus every import specifier the move invalidates.
4
+ // No I/O: the caller has already read the sources and built the graph.
5
+
6
+ import type { BoundaryRule, BoundaryViolation, ImportGraph, Surface } from '@ultimat3/render';
7
+ import { checkSurfaceBoundary, surfaceOf } from '@ultimat3/render';
8
+ import type { BoundaryCode } from './app-boundaries';
9
+ import { boundaryCodeOf, relativeSpecifier, resolveSpecifier } from './app-boundaries';
10
+
11
+ /** One specifier the move invalidates: where it is written, what it names, what it must become. */
12
+ export interface SpecifierEdit {
13
+ /** The file to edit, at the path it holds once the move is applied. */
14
+ readonly file: string;
15
+ /** The file the specifier resolves to today — how a caller finds it among that file's imports. */
16
+ readonly imported: string;
17
+ /** Its replacement, relative to `file`'s own directory after the move. */
18
+ readonly specifier: string;
19
+ }
20
+
21
+ /** Generated only when moving the module is what removes the violated edge. */
22
+ export interface BoundarySplit {
23
+ readonly module: string;
24
+ readonly surface: Surface;
25
+ readonly to: string;
26
+ /** `git mv <module> <to>` — runnable as-is from the app root. */
27
+ readonly command: string;
28
+ /** Direct importers of `module` — the files whose specifier needs the new path. */
29
+ readonly importers: readonly string[];
30
+ /** The other half of the repair: every specifier `command` alone would leave dangling. */
31
+ readonly edits: readonly SpecifierEdit[];
32
+ }
33
+
34
+ export interface BoundaryCut {
35
+ readonly code: BoundaryCode;
36
+ readonly rule: BoundaryRule;
37
+ readonly entry: string;
38
+ /** The file to edit. */
39
+ readonly at: string;
40
+ /** The single edge a caller can delete to clear this violation. */
41
+ readonly edge: { readonly from: string; readonly to: string };
42
+ readonly chain: readonly string[];
43
+ readonly cause: string;
44
+ readonly edit: string;
45
+ readonly split: BoundarySplit | null;
46
+ }
47
+
48
+ const involves = (violation: BoundaryViolation, target: string): boolean =>
49
+ violation.entry === target || violation.importer === target || violation.chain.includes(target);
50
+
51
+ function reverseGraph(graph: ImportGraph): ReadonlyMap<string, ReadonlySet<string>> {
52
+ const reverse = new Map<string, Set<string>>();
53
+ for (const [file, refs] of graph) {
54
+ for (const ref of refs) {
55
+ const predecessors = reverse.get(ref.file) ?? new Set<string>();
56
+ predecessors.add(file);
57
+ reverse.set(ref.file, predecessors);
58
+ }
59
+ }
60
+ return reverse;
61
+ }
62
+
63
+ /** Files with a specifier that resolves straight onto `module` — exactly who `git mv` breaks. */
64
+ function directImporters(graph: ImportGraph, module: string): readonly string[] {
65
+ const found: string[] = [];
66
+ for (const [file, refs] of graph) {
67
+ if (refs.some((ref) => ref.file === module)) found.push(file);
68
+ }
69
+ return found.sort();
70
+ }
71
+
72
+ /**
73
+ * Every non-`shared/` surface that transitively reaches `module`, through any number of further
74
+ * `shared/` hops. That is what "actually reach" means: a `shared/` re-export only counts once
75
+ * the walk lands outside `shared/` entirely.
76
+ */
77
+ function surfacesReaching(graph: ImportGraph, module: string): readonly Surface[] {
78
+ const reverse = reverseGraph(graph);
79
+ const seen = new Set<string>([module]);
80
+ const queue: string[] = [module];
81
+ const surfaces = new Set<Surface>();
82
+ while (queue.length > 0) {
83
+ const current = queue.shift();
84
+ if (current === undefined) break;
85
+ for (const predecessor of reverse.get(current) ?? []) {
86
+ const surface = surfaceOf(predecessor);
87
+ if (surface !== null && surface !== 'shared') surfaces.add(surface);
88
+ if (seen.has(predecessor)) continue;
89
+ seen.add(predecessor);
90
+ queue.push(predecessor);
91
+ }
92
+ }
93
+ return [...surfaces].sort();
94
+ }
95
+
96
+ /** `apps/web/shared/ui/panel.tsx` relocated under `app/` → `apps/web/app/ui/panel.tsx`. */
97
+ const relocate = (path: string, surface: Surface): string =>
98
+ path.replace(/(^|\/)shared\//, `$1${surface}/`);
99
+
100
+ /**
101
+ * Every specifier the move breaks: each direct importer's path onto `module`, and `module`'s own
102
+ * relative imports, which travel with the file and stop resolving from its new directory. A move
103
+ * published without them is a repair that leaves the tree not building.
104
+ */
105
+ function specifierEdits(
106
+ graph: ImportGraph,
107
+ module: string,
108
+ to: string,
109
+ importers: readonly string[],
110
+ ): readonly SpecifierEdit[] {
111
+ const keys = new Set(graph.keys());
112
+ const edits: SpecifierEdit[] = importers.map((file) => ({
113
+ file,
114
+ imported: module,
115
+ specifier: relativeSpecifier(file, to),
116
+ }));
117
+ for (const ref of graph.get(module) ?? []) {
118
+ // Only edges that land on a scanned file: a bare package specifier does not move with the
119
+ // file, and the graph never held the text of one, so a rewrite for it would be a guess.
120
+ if (!keys.has(ref.file)) continue;
121
+ // Sibling surfaces sit at the same depth, so a `../` specifier survives the move untouched.
122
+ const written = relativeSpecifier(module, ref.file);
123
+ if (resolveSpecifier(to, written, keys) === ref.file) continue;
124
+ edits.push({ file: to, imported: ref.file, specifier: relativeSpecifier(to, ref.file) });
125
+ }
126
+ return edits;
127
+ }
128
+
129
+ /**
130
+ * A relocation repairs a violation only when the module lands on the same surface as the file it
131
+ * imports — that is what turns the flagged cross-surface edge into a legal same-surface one.
132
+ * `site-imports-app` never qualifies: a module `site/` reaches carries the identical `site/ →
133
+ * app/` edge into `site/` with it, so the move would relocate the file and fix nothing.
134
+ */
135
+ const relocationRepairs = (violation: BoundaryViolation, surface: Surface): boolean =>
136
+ violation.rule === 'shared-is-a-leaf' && surfaceOf(violation.imported) === surface;
137
+
138
+ /**
139
+ * The `shared/` fattening remedy. One surface reaching `module` means it was never shared, so
140
+ * the cut is mechanical. Two or more means it really is shared and a human has to decide which
141
+ * part goes where — never invent a split the graph cannot justify.
142
+ */
143
+ function planSplit(
144
+ graph: ImportGraph,
145
+ violation: BoundaryViolation,
146
+ surfaces: readonly Surface[],
147
+ ): BoundarySplit | null {
148
+ if (surfaces.length !== 1) return null;
149
+ const [surface] = surfaces;
150
+ if (surface === undefined || !relocationRepairs(violation, surface)) return null;
151
+ const module = violation.importer;
152
+ const to = relocate(module, surface);
153
+ const importers = directImporters(graph, module);
154
+ return {
155
+ module,
156
+ surface,
157
+ to,
158
+ command: `git mv ${module} ${to}`,
159
+ importers,
160
+ edits: specifierEdits(graph, module, to, importers),
161
+ };
162
+ }
163
+
164
+ /** The move alone is half a repair, so it is published with every rewrite it forces. */
165
+ const splitEdit = (split: BoundarySplit): string =>
166
+ split.edits.length === 0
167
+ ? split.command
168
+ : `${split.command} # then ${split.edits
169
+ .map((edit) => `in ${edit.file}, ${edit.imported} → '${edit.specifier}'`)
170
+ .join('; ')}`;
171
+
172
+ /**
173
+ * No relocation clears this one, so the instruction is the edit itself: which import to delete,
174
+ * and the two ways out — hoist what the module needs into the module, or invert the call so the
175
+ * surface that reaches it passes the value in.
176
+ */
177
+ function manualEdit(violation: BoundaryViolation, surfaces: readonly Surface[]): string {
178
+ const reach = surfaces.length === 0 ? 'no surface' : surfaces.join(' and ');
179
+ return `${violation.importer} is reached by ${reach}, so relocating it keeps the edge: delete the import of ${violation.imported} in ${violation.importer} — move what it needs into ${violation.importer}, or have the caller pass it in`;
180
+ }
181
+
182
+ function editFor(
183
+ violation: BoundaryViolation,
184
+ split: BoundarySplit | null,
185
+ surfaces: readonly Surface[],
186
+ ): string {
187
+ if (split !== null) return splitEdit(split);
188
+ if (violation.rule === 'app-imports-api-at-runtime') return violation.fix;
189
+ if (surfaceOf(violation.importer) === 'shared') return manualEdit(violation, surfaces);
190
+ return `delete the import of ${violation.imported} in ${violation.importer}`;
191
+ }
192
+
193
+ function toCut(violation: BoundaryViolation, graph: ImportGraph): BoundaryCut {
194
+ const module = violation.importer;
195
+ const surfaces = surfaceOf(module) === 'shared' ? surfacesReaching(graph, module) : [];
196
+ const split = planSplit(graph, violation, surfaces);
197
+ return {
198
+ code: boundaryCodeOf(violation.rule),
199
+ rule: violation.rule,
200
+ entry: violation.entry,
201
+ at: violation.importer,
202
+ edge: { from: violation.importer, to: violation.imported },
203
+ chain: violation.chain,
204
+ cause: violation.cause,
205
+ edit: editFor(violation, split, surfaces),
206
+ split,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Every boundary violation that touches `target`, turned into a printable cut. Pure — the
212
+ * caller has already read the app's sources and built the graph (`readAppSources` +
213
+ * `appImportGraph`), so this runs the same way in a test as it does in the CLI.
214
+ */
215
+ export function planBoundaryCuts(target: string, graph: ImportGraph): readonly BoundaryCut[] {
216
+ return checkSurfaceBoundary(graph)
217
+ .filter((violation) => involves(violation, target))
218
+ .map((violation) => toCut(violation, graph));
219
+ }
package/src/budgets.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Per-route budgets. A blown budget is a build failure, not a Lighthouse report nobody read —
2
+ // and the finding names the import chain that caused it, because "your bundle got bigger" is not
3
+ // an actionable message for a human or an agent.
4
+ //
5
+ // Byte parsing and formatting come from `@ultimat3/render`, which owns the budget vocabulary the
6
+ // routes are declared in. The one thing that lives here is the comparison against MEASURED bytes:
7
+ // render checks a bundle graph, this checks what the build actually emitted.
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type { Manifest, RouteFact } from '@ultimat3/manifest';
12
+ import { formatBytes, parseByteBudget } from '@ultimat3/render';
13
+ import type { Finding } from './output';
14
+
15
+ export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
16
+
17
+ export interface RouteStats {
18
+ readonly path: string;
19
+ readonly jsBytes: number;
20
+ readonly lcpMs?: number;
21
+ /** Import chain that pulled the heaviest module into this route. */
22
+ readonly heaviestChain?: readonly string[];
23
+ }
24
+
25
+ export interface BuildStats {
26
+ readonly routes: readonly RouteStats[];
27
+ }
28
+
29
+ const chainOf = (stats: RouteStats): string =>
30
+ stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
31
+
32
+ const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.budget?.js);
33
+
34
+ /** Which budgets the route declared, for a cause line that names what went unmeasured. */
35
+ function declaredBudgets(js: number | null, lcp: number | undefined): string {
36
+ const labels: string[] = [];
37
+ if (js !== null) labels.push('JS');
38
+ if (lcp !== undefined) labels.push('LCP');
39
+ return labels.join(' and ');
40
+ }
41
+
42
+ /**
43
+ * Compare declared budgets against measured stats. A declared budget with no measurement is a
44
+ * finding, never a pass: a route that clears the gate without ever being weighed is exactly the
45
+ * false green axiom 5 exists to prevent. Only a route that declares nothing is skipped.
46
+ */
47
+ export function checkBudgets(manifest: Manifest, stats: BuildStats): readonly Finding[] {
48
+ const byPath = new Map(stats.routes.map((route) => [route.path, route]));
49
+ const findings: Finding[] = [];
50
+ for (const route of manifest.routes) {
51
+ const measured = byPath.get(route.url);
52
+ const js = jsBudgetOf(route);
53
+ const lcp = route.budget?.lcp;
54
+ if (measured === undefined) {
55
+ if (js !== null || lcp !== undefined) {
56
+ findings.push({
57
+ code: 'X_BUDGET_UNMEASURED',
58
+ cause: `${route.url} declares a ${declaredBudgets(js, lcp)} budget but ${BUILD_STATS_FILE} has no entry for it`,
59
+ fix: 'x build && x verify',
60
+ docs: 'https://ultimate.dev/errors/X_BUDGET_UNMEASURED',
61
+ at: route.url,
62
+ });
63
+ }
64
+ continue;
65
+ }
66
+ if (js !== null && measured.jsBytes > js) {
67
+ findings.push({
68
+ code: 'X_BUDGET_EXCEEDED',
69
+ cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
70
+ fix: `x routes --json to see the chain, then move the heavy import behind hydrate: 'interaction'`,
71
+ docs: 'https://ultimate.dev/errors/X_BUDGET_EXCEEDED',
72
+ at: route.url,
73
+ });
74
+ }
75
+ if (lcp !== undefined && measured.lcpMs !== undefined && measured.lcpMs > lcp) {
76
+ findings.push({
77
+ code: 'X_BUDGET_EXCEEDED',
78
+ cause: `${route.url} LCP ${measured.lcpMs}ms over the ${lcp}ms budget`,
79
+ fix: `raise the budget in defineRoute, or switch render to 'isr' to serve it prebuilt`,
80
+ docs: 'https://ultimate.dev/errors/X_BUDGET_EXCEEDED',
81
+ at: route.url,
82
+ });
83
+ }
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ export async function readBuildStats(root: string): Promise<BuildStats | undefined> {
89
+ const path = join(root, BUILD_STATS_FILE);
90
+ if (!existsSync(path)) return undefined;
91
+ return (await Bun.file(path).json()) as BuildStats;
92
+ }
@@ -0,0 +1,109 @@
1
+ // `x build --target docker|binary|static` — three targets, no platform primitives. Deploy anywhere
2
+ // means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
3
+
4
+ import { join } from 'node:path';
5
+ import { requireAppRoot } from './app-root';
6
+ import { runVerify } from './cmd-verify';
7
+ import type { CliCommand, CommandContext } from './command';
8
+ import { UnknownCommandError } from './errors';
9
+ import { execOutput } from './exec';
10
+ import { msg } from './messages';
11
+ import type { CommandResult, Finding } from './output';
12
+ import { flagString } from './parse';
13
+
14
+ export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
15
+
16
+ export type BuildTarget = (typeof BUILD_TARGETS)[number];
17
+
18
+ export function readTarget(raw: string | undefined): BuildTarget {
19
+ const targets: readonly string[] = BUILD_TARGETS;
20
+ if (raw === undefined) return 'docker';
21
+ if (targets.includes(raw)) return raw as BuildTarget;
22
+ throw new UnknownCommandError({
23
+ path: `build --target ${raw}`,
24
+ known: BUILD_TARGETS,
25
+ suggestion: 'build --target docker',
26
+ });
27
+ }
28
+
29
+ /** One image for every role; ROLE selects behaviour at start, so there is one artifact to promote. */
30
+ export function dockerArgs(root: string, tag: string): readonly string[] {
31
+ return ['docker', 'build', '-f', join(root, 'docker', 'Dockerfile'), '-t', tag, root];
32
+ }
33
+
34
+ export function binaryArgs(root: string, out: string): readonly string[] {
35
+ return [
36
+ 'bun',
37
+ 'build',
38
+ '--compile',
39
+ '--minify',
40
+ join(root, 'apps', 'web', 'server.ts'),
41
+ '--outfile',
42
+ out,
43
+ ];
44
+ }
45
+
46
+ export function staticArgs(root: string, out: string): readonly string[] {
47
+ return ['bun', 'run', join(root, 'apps', 'web', 'prerender.ts'), '--out', out];
48
+ }
49
+
50
+ export function argsFor(
51
+ target: BuildTarget,
52
+ paths: { readonly root: string; readonly tag: string; readonly out: string },
53
+ ): readonly string[] {
54
+ if (target === 'docker') return dockerArgs(paths.root, paths.tag);
55
+ if (target === 'binary') return binaryArgs(paths.root, paths.out);
56
+ return staticArgs(paths.root, paths.out);
57
+ }
58
+
59
+ export const buildCommand: CliCommand = {
60
+ spec: {
61
+ name: 'build',
62
+ summary: 'build a container image, a single binary, or a prerendered static site',
63
+ usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
64
+ requiresApp: true,
65
+ flags: [
66
+ { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
67
+ { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
68
+ { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
69
+ ],
70
+ },
71
+ async run(ctx: CommandContext): Promise<CommandResult> {
72
+ const root = requireAppRoot('build', ctx.cwd).dir;
73
+ const target = readTarget(flagString(ctx.args, 'target'));
74
+
75
+ // Run static verify steps before building.
76
+ const staticSteps = ['typecheck', 'lint', 'boundaries', 'filesize', 'package-shape', 'errors'];
77
+ const verifySteps = (await import('./cmd-verify')).VERIFY_STEPS.filter((step) =>
78
+ staticSteps.includes(step.name),
79
+ );
80
+ const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner });
81
+ if (!verifyResult.ok) {
82
+ return verifyResult;
83
+ }
84
+
85
+ const out =
86
+ flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
87
+ const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
88
+ const command = argsFor(target, { root, tag, out });
89
+ const result = await ctx.runner(command, { cwd: root });
90
+ const findings: readonly Finding[] = result.ok
91
+ ? []
92
+ : [
93
+ {
94
+ code: 'X_BUILD_FAILED',
95
+ cause: `${command.join(' ')} exited ${result.code}`,
96
+ fix: target === 'docker' ? 'x doctor --json && docker info' : 'x verify --json',
97
+ docs: 'https://ultimate.dev/errors/X_BUILD_FAILED',
98
+ },
99
+ ];
100
+ return {
101
+ ok: result.ok,
102
+ command: 'build',
103
+ summary: msg('cli.build.done', { target }),
104
+ findings,
105
+ data: { target, artifact: target === 'docker' ? tag : out, durationMs: result.durationMs },
106
+ lines: result.ok ? [] : execOutput(result).split('\n'),
107
+ };
108
+ },
109
+ };