@ultimat3/cli 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -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 +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  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 +14 -8
  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 +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/README.md CHANGED
@@ -11,17 +11,23 @@ Commands and the `x verify` step count, `As of 2026-08`:
11
11
  | `x new <name>` | scaffolds the monorepo | interactive-free; auth, seeded DB, example route |
12
12
  | `x dev` | every role in one process | embedded Postgres/events/storage, `/_x` mounted |
13
13
  | `x build --target docker\|binary\|static` | one artifact | `ROLE` selects behaviour at start |
14
- | `x verify` | **the gate** | 16 named steps, each with pass/fail + duration |
14
+ | `x verify` | **the gate** | 17 named steps, each with pass/fail + duration |
15
15
  | `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
16
- | `x db gen\|migrate\|reset\|studio\|branch` | everything DB | `branch` = copy-on-write clone + preview URL |
16
+ | `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
17
17
  | `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
18
18
  | `x doctor` | environment, ports, drift, PWA prerequisites | every finding carries a fix command |
19
19
  | `x deploy` | container deploy plan | compose or helm; zero platform primitives |
20
20
  | `x manifest` / `x routes` | generated facts | `x.manifest.json`, `openapi.json`, route table |
21
21
  | `x actions` / `x queries` / `x entities` | the declaration registries | `list` and `describe <name>`, straight off the registries |
22
- | `x jobs ls\|show\|retry\|drain` | the queue | depth, dead letters, step traces, `retry --from-step`, `drain --to` |
22
+ | `x tasks list\|show` | cron tasks | timezone and next run, off `registeredTasks()` |
23
+ | `x jobs ls\|show\|retry\|cancel\|drain` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`, `drain --to` |
23
24
  | `x test [type]` | one of the six test types, or all | same type rule as the gate; `--filter`, `--sample N` |
24
- | `x errors explain <CODE>` | the error table, programmatically | refuses an unregistered code instead of inventing one |
25
+ | `x env check\|example` | the typed environment `envSchema` declares | and the `.env.example` rendered from it |
26
+ | `x secrets show\|init\|edit\|set\|rotate` | the committed encrypted secrets | decrypted into the `envSchema` variables of the same names |
27
+ | `x policy list\|explain <subject>` | which clause decided a permission, and why | five packages print `x policy explain` as a denial's `fix:` |
28
+ | `x i18n check\|add\|sync` | catalogs: gaps, a new locale, key sync | all three of i18n's own error fixes name it |
29
+ | `x errors explain <CODE>` / `list` | the error table, programmatically | refuses an unregistered code instead of inventing one |
30
+ | `x docs "<question>"` | the framework docs, offline | answered from the installed packages, never the network |
25
31
  | `x fix boundary <file>` | the minimal cut for a crossed surface boundary | prints the plan and the `git mv`; never rewrites a file |
26
32
 
27
33
  Everything in [CLI reference](../../wiki/CLI-Reference.md)'s planned table is also in the registry
@@ -41,28 +47,46 @@ X_DB_DRIFT: schema differs from migrations
41
47
 
42
48
  ```sh
43
49
  x verify --json
44
- # {"ok":false,"command":"verify","summary":"1 of 16 steps failed","steps":[...]}
50
+ # {"ok":false,"command":"verify","summary":"1 of 17 steps failed","steps":[...]}
45
51
  ```
46
52
 
47
53
  ## `x verify` steps
48
54
 
49
55
  `typecheck lint boundaries filesize package-shape errors unit contract live job e2e eval drift
50
- contract-diff budgets manifest`
56
+ contract-diff budgets manifest roadmap`
51
57
 
52
- One list, in cost order, defined once in `cmd-verify.ts` — the framework repo's own gate
53
- (`bun run verify`) runs exactly it. A step with nothing to check here reports as skipped, never as
58
+ Seventeen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
59
+ count above is projected from that list, and the framework repo's own gate (`bun run verify`)
60
+ runs exactly it. A step with nothing to check here reports as skipped, never as
54
61
  passed. Never bails early: an agent fixing three things needs all three findings from one run.
55
62
  There is no `--only` and no `--skip`; the exit code is non-zero if any step fails.
56
63
 
64
+ A committed `x.verify.json` is the floor, `As of 2026-08`: it names the steps this repo has already
65
+ proved it can run, and a step it names that reports nothing is `X_VERIFY_SUITE_VANISHED` rather
66
+ than a skip.
67
+ "Nothing" is both ways a suite disappears — no files at all, and every test in the files it found
68
+ skipping itself, which is read back out of `bun test`'s own summary. `x new` writes one.
69
+
70
+ An app extends the gate with its own conventions, never with its own step: a file in `guards/`
71
+ exports a `guard` whose `check(root)` returns `Finding[]`, and the `boundaries` step runs every one
72
+ of them. Nothing registers a guard — the directory is the registration — and what a guard returns
73
+ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUARD_FAILED`,
74
+ `X_GUARD_FINDING_INVALID`). `x g guard <name>` scaffolds one with its test.
75
+
57
76
  ## Layout
58
77
 
59
78
  | File | Responsibility |
60
79
  |---|---|
61
80
  | `bin.ts` | argv, stdout, exit code — nothing else |
81
+ | `write-line.ts` | the synchronous fd-1 write both published entry points use (`create-ultimate`'s too) |
62
82
  | `dispatch.ts` | parse → run → render → exit; the only I/O boundary |
63
83
  | `parse.ts` | flags, subcommands, `--json`, `--help`, suggestions |
84
+ | `flag-number.ts` | the one integer-flag reader — `--port`, `--workers`, `--shard` |
85
+ | `shell-quote.ts` | the one POSIX quoter for a value pasted into a `fix:` or a reproduce line |
64
86
  | `output.ts` | one data shape, two renderers, the 3-line error format |
65
87
  | `registry.ts` | the one command list |
88
+ | `generate-kinds.ts` | which generators exist, and how a command line names one |
89
+ | `guards.ts` | the app's own conventions: `guards/` discovered, run, and held to the error contract |
66
90
  | `cmd-*.ts` | one command group each |
67
91
  | `templates/` | scaffolding as typed string modules, not copied fixtures |
68
92
  | `app-load.ts` | import an app's modules so the framework registries hold it |
@@ -70,7 +94,13 @@ There is no `--only` and no `--skip`; the exit code is non-zero if any step fail
70
94
  | `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
71
95
  | `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
72
96
  | `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
97
+ | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it |
98
+ | `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
99
+ | `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT` |
100
+ | `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
73
101
  | `dev-*.ts` | what `x dev` boots: services, runtime, routes, hooks, roles, the `/_x` mount |
102
+ | `island-bundle.ts` | every `*.island.tsx` built as its own entry point, content-hashed |
103
+ | `island-routes.ts` | the one route those chunks are served from, in dev and in the container |
74
104
  | `mcp-host.ts` | the shell-side half of `@ultimat3/mcp`'s dev server — db, tests, logs, verify |
75
105
  | `verify-step.ts` | the step shape, the step names, the host-check hook |
76
106
  | `verify-tests.ts` | one `bun test` invocation per test type |
@@ -90,6 +120,9 @@ no second OpenAPI builder and no second surface-boundary walk anywhere in this p
90
120
  apps/web/app/<feature>/{entity,repo,service,policy,errors,ui}.ts
91
121
  apps/web/app/<feature>/{actions,queries,live,jobs,tasks}/<name>.ts
92
122
  apps/web/{site,app}/<path>/page.tsx
123
+ apps/web/{site,app}/<path>/<name>.island.tsx # x g island <name> --at <dir>
124
+ apps/admin/src/pages/<name>.tsx # x g admin:page <name> --permission p
125
+ guards/<name>.ts # x g guard <name>
93
126
  ```
94
127
 
95
128
  Every emitted source has a `<file>.test.ts` beside it that passes on the first run.
@@ -97,4 +130,4 @@ Every emitted source has a `<file>.test.ts` beside it that passes on the first r
97
130
  ## Errors
98
131
 
99
132
  `X_CLI_UNKNOWN_COMMAND` `X_CLI_BAD_FLAG` `X_VERIFY_FAILED` `X_NOT_IN_APP` `X_BUN_VERSION`
100
- `X_NOT_IMPLEMENTED`
133
+ `X_NOT_IMPLEMENTED` `X_GUARD_INVALID` `X_GUARD_FAILED` `X_GUARD_FINDING_INVALID`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "CLAUDE.md",
25
26
  "README.md",
26
27
  "LICENSE"
27
28
  ],
@@ -34,27 +35,28 @@
34
35
  "dev": "bun run src/bin.ts dev"
35
36
  },
36
37
  "dependencies": {
37
- "@ultimat3/action": "1.2.0",
38
- "@ultimat3/admin": "1.2.0",
39
- "@ultimat3/ai": "1.2.0",
40
- "@ultimat3/cache": "1.2.0",
41
- "@ultimat3/core": "1.2.0",
42
- "@ultimat3/db": "1.2.0",
43
- "@ultimat3/entity": "1.2.0",
44
- "@ultimat3/http": "1.2.0",
45
- "@ultimat3/i18n": "1.2.0",
46
- "@ultimat3/jobs": "1.2.0",
47
- "@ultimat3/mail": "1.2.0",
48
- "@ultimat3/manifest": "1.2.0",
49
- "@ultimat3/mcp": "1.2.0",
50
- "@ultimat3/policy": "1.2.0",
51
- "@ultimat3/pwa": "1.2.0",
52
- "@ultimat3/query": "1.2.0",
53
- "@ultimat3/realtime": "1.2.0",
54
- "@ultimat3/render": "1.2.0",
55
- "@ultimat3/seo": "1.2.0",
56
- "@ultimat3/storage": "1.2.0",
57
- "@ultimat3/testing": "1.2.0",
58
- "@ultimat3/time": "1.2.0"
38
+ "@ultimat3/action": "3.0.0",
39
+ "@ultimat3/admin": "3.0.0",
40
+ "@ultimat3/ai": "3.0.0",
41
+ "@ultimat3/cache": "3.0.0",
42
+ "@ultimat3/core": "3.0.0",
43
+ "@ultimat3/db": "3.0.0",
44
+ "@ultimat3/entity": "3.0.0",
45
+ "@ultimat3/http": "3.0.0",
46
+ "@ultimat3/i18n": "3.0.0",
47
+ "@ultimat3/jobs": "3.0.0",
48
+ "@ultimat3/mail": "3.0.0",
49
+ "@ultimat3/manifest": "3.0.0",
50
+ "@ultimat3/mcp": "3.0.0",
51
+ "@ultimat3/policy": "3.0.0",
52
+ "@ultimat3/pwa": "3.0.0",
53
+ "@ultimat3/query": "3.0.0",
54
+ "@ultimat3/realtime": "3.0.0",
55
+ "@ultimat3/render": "3.0.0",
56
+ "@ultimat3/schema": "3.0.0",
57
+ "@ultimat3/seo": "3.0.0",
58
+ "@ultimat3/storage": "3.0.0",
59
+ "@ultimat3/testing": "3.0.0",
60
+ "@ultimat3/time": "3.0.0"
59
61
  }
60
62
  }
@@ -0,0 +1,16 @@
1
+ // The app's API over HTTP, composed once: the write half `@ultimat3/action` projects and the read
2
+ // half `@ultimat3/query` projects. `x dev` and `serve.ts` both mount THIS rather than each listing
3
+ // the registries themselves — two lists is how `query.client()` shipped deriving `/_x/query/<kebab>`
4
+ // against a route neither file mounted, compiling everywhere and 404ing everywhere.
5
+
6
+ import { listActions, toRoute } from '@ultimat3/action';
7
+ import type { Route } from '@ultimat3/http';
8
+ import { listQueries, toQueryRoute } from '@ultimat3/query';
9
+
10
+ /**
11
+ * Whatever loading the app's modules put in the two registries, as routes. Read at call time,
12
+ * never at import: importing the app IS the registration, and it happens after this module loads.
13
+ */
14
+ export function apiRoutes(): readonly Route[] {
15
+ return [...listActions().map(toRoute), ...listQueries().map(toQueryRoute)];
16
+ }
@@ -0,0 +1,32 @@
1
+ // The one fact `x dev` and `serve.ts` need out of `app.config.ts` before a listener binds: where
2
+ // this app's sign-in page is. Sibling of `app-env.ts`'s `loadEnvSchema`, and imports the config
3
+ // for the same reason — a regex over the app's source is the pattern `app-load.ts` refuses.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { APP_CONFIG_FILE } from './app-root';
8
+
9
+ /** The export every app declares. Named, never default — the CLI and the runtime both import it. */
10
+ export const APP_CONFIG_EXPORT = 'config';
11
+
12
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
13
+ typeof value === 'object' && value !== null;
14
+
15
+ /**
16
+ * `auth.signInPath`, or `null` when the app declares none.
17
+ *
18
+ * Structural, not `instanceof`: `defineConfig` returns a plain object, and a config that resolved
19
+ * through an older version of core simply has no `auth` section. `null` is the safe answer either
20
+ * way — it is what turns the browser redirect off and leaves the problem document in place.
21
+ */
22
+ export async function loadSignInPath(root: string): Promise<string | null> {
23
+ const configPath = join(root, APP_CONFIG_FILE);
24
+ if (!existsSync(configPath)) return null;
25
+ const module = (await import(configPath)) as Record<string, unknown>;
26
+ const config = module[APP_CONFIG_EXPORT];
27
+ if (!isRecord(config)) return null;
28
+ const auth = config['auth'];
29
+ if (!isRecord(auth)) return null;
30
+ const path = auth['signInPath'];
31
+ return typeof path === 'string' && path.startsWith('/') ? path : null;
32
+ }
@@ -0,0 +1,18 @@
1
+ // How many entities an app declares, answered the way this package answers every question about a
2
+ // primitive: load the app, then project the registry — never a scan of the source. An entity may be
3
+ // declared anywhere `loadApp` reaches, so a text rule over `packages/db/src` would miss the ones
4
+ // that are not there and mistake a type-only import for a declaration.
5
+
6
+ import { describeEntities } from '@ultimat3/entity';
7
+ import { loadApp } from './app-load';
8
+
9
+ /**
10
+ * A module that will not import registers nothing, so a broken app answers with a SHORT count and
11
+ * never a throw — `loadApp` collects import failures as findings rather than raising them. Callers
12
+ * must therefore read a zero as "nothing is declared *that this process could load*", which is why
13
+ * the one caller uses it to go quiet rather than to accuse.
14
+ */
15
+ export async function countDeclaredEntities(root: string): Promise<number> {
16
+ await loadApp(root);
17
+ return describeEntities().length;
18
+ }
package/src/app-env.ts ADDED
@@ -0,0 +1,103 @@
1
+ // The app's typed environment as the CLI sees it: the `defineEnv` declaration read back out of
2
+ // `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
3
+ // declaration, both files (axiom 2) — nothing here holds a second list of variable names.
4
+
5
+ // Bun ships no equivalent: `existsSync` answers whether this root is an app, `join` builds the
6
+ // host-separator path to the two files this module reads.
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
10
+ import { checkEnvExample, ENV_EXAMPLE_PATH, renderEnvExample } from '@ultimat3/core';
11
+ import { APP_CONFIG_FILE } from './app-root';
12
+ import type { Finding } from './output';
13
+ import { findingFrom } from './output';
14
+
15
+ /**
16
+ * The one export name the CLI looks for. `defineEnv()` returns the resolved VALUES, so the
17
+ * declaration it validated is unreachable from its result — an app that wants `.env.example`,
18
+ * `x env check` and the drift gate names the record it passed in:
19
+ *
20
+ * ```ts
21
+ * export const envSchema = { DATABASE_URL: { type: 'url', … } } satisfies EnvSchema;
22
+ * export const env = defineEnv(envSchema);
23
+ * ```
24
+ *
25
+ * An app that exports no `envSchema` declares no environment, so there is nothing to project and
26
+ * nothing to drift — every check here reports nothing rather than inventing a requirement.
27
+ */
28
+ export const ENV_SCHEMA_EXPORT = 'envSchema';
29
+
30
+ const ENV_TYPES = new Set(['string', 'url', 'number', 'integer', 'port', 'boolean', 'enum']);
31
+
32
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
33
+ typeof value === 'object' && value !== null;
34
+
35
+ const isDecl = (value: unknown): value is EnvVarDecl =>
36
+ isRecord(value) && typeof value['type'] === 'string' && ENV_TYPES.has(value['type']);
37
+
38
+ /** Structural, not `instanceof`: the schema is a plain record the app authored, never a class. */
39
+ export const isEnvSchema = (value: unknown): value is EnvSchema =>
40
+ isRecord(value) && Object.values(value).every(isDecl);
41
+
42
+ /**
43
+ * Import `app.config.ts` and hand back the declaration it exports. Importing is the only honest
44
+ * way to read it — the alternative is a regex over the app's source, which is the pattern
45
+ * `app-load.ts` exists to refuse. `undefined` means "this root declares no environment"; a config
46
+ * that will not import throws, and the caller turns that into the finding.
47
+ */
48
+ export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined> {
49
+ const configPath = join(root, APP_CONFIG_FILE);
50
+ if (!existsSync(configPath)) return undefined;
51
+ const module = (await import(configPath)) as Record<string, unknown>;
52
+ const declared = module[ENV_SCHEMA_EXPORT];
53
+ if (declared === undefined) return undefined;
54
+ return isEnvSchema(declared) ? declared : undefined;
55
+ }
56
+
57
+ /** The bytes `.env.example` must hold. Deterministic, so a rewrite that changes nothing diffs to nothing. */
58
+ export const envExampleFor = (schema: EnvSchema): string => renderEnvExample(schema);
59
+
60
+ const driftFinding = (cause: string): Finding => ({
61
+ code: 'X_ENV_EXAMPLE_DRIFT',
62
+ cause,
63
+ // The generator, not the assertion: `assertEnvExample`'s own fix is a `Bun.write(…)` call for
64
+ // an app that has a schema object in scope, and a gate reader has a shell.
65
+ fix: 'x env example',
66
+ docs: 'https://ultimate.dev/errors/X_ENV_EXAMPLE_DRIFT',
67
+ at: ENV_EXAMPLE_PATH,
68
+ });
69
+
70
+ /**
71
+ * The gate half. Byte-exact against the projection, not just "every key is present somewhere":
72
+ * the example carries each variable's description, whether it is required and its default, and a
73
+ * key-only rule would let all three rot while the file still passed. Missing keys are still called
74
+ * out by name first, because that is the failure a reader can act on without diffing.
75
+ */
76
+ export async function envExampleFindings(root: string): Promise<readonly Finding[]> {
77
+ let schema: EnvSchema | undefined;
78
+ try {
79
+ schema = await loadEnvSchema(root);
80
+ } catch (error) {
81
+ return [{ ...findingFrom(error), at: APP_CONFIG_FILE }];
82
+ }
83
+ if (schema === undefined) return [];
84
+ const expected = envExampleFor(schema);
85
+ const file = Bun.file(join(root, ENV_EXAMPLE_PATH));
86
+ if (!(await file.exists())) {
87
+ return [
88
+ driftFinding(
89
+ `${ENV_EXAMPLE_PATH} does not exist and ${ENV_SCHEMA_EXPORT} declares ${Object.keys(schema).length} variable(s)`,
90
+ ),
91
+ ];
92
+ }
93
+ const text = await file.text();
94
+ if (text === expected) return [];
95
+ const report = checkEnvExample(schema, text);
96
+ return [
97
+ driftFinding(
98
+ report.missing.length > 0
99
+ ? `${ENV_EXAMPLE_PATH} does not declare ${report.missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
100
+ : `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
101
+ ),
102
+ ];
103
+ }
package/src/app-load.ts CHANGED
@@ -10,7 +10,7 @@ import { registerActions } from '@ultimat3/action';
10
10
  import { localeConfig } from '@ultimat3/i18n';
11
11
  import type { ErrorCodeFact } from '@ultimat3/manifest';
12
12
  import { registerQueries } from '@ultimat3/query';
13
- import { isRouteConfig, registerRoute } from '@ultimat3/render';
13
+ import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
14
14
  import { collectDeclaredCodes } from './error-contract';
15
15
  import type { Finding } from './output';
16
16
  import { findingFrom } from './output';
@@ -31,6 +31,14 @@ const APP_GLOBS = [
31
31
  */
32
32
  const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
33
33
 
34
+ /**
35
+ * A `*.island.tsx` is a CLIENT entry point and is deliberately not imported here. It registers no
36
+ * primitive — a page names it by specifier, never by import — and importing it would put the one
37
+ * module the framework guarantees is outside the server's graph inside this process's, where a
38
+ * top-level `document` reference takes the whole scan down (axiom 6).
39
+ */
40
+ const CLIENT_ENTRY_POINT = /\.island\.tsx$/;
41
+
34
42
  export interface LoadedApp {
35
43
  readonly root: string;
36
44
  /** App-root-relative POSIX paths of every module that imported, sorted. */
@@ -73,7 +81,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
73
81
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
74
82
  if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
75
83
  const file = relative(root, absolute).split(sep).join('/');
76
- if (ENTRY_POINT.test(file)) continue;
84
+ if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file)) continue;
77
85
  let module: Record<string, unknown>;
78
86
  try {
79
87
  module = (await import(absolute)) as Record<string, unknown>;
@@ -114,7 +122,16 @@ async function register(
114
122
  // The build counts boundaries from the compiled JSX; before a build there is only the
115
123
  // source, and `render: 'stream'` is rejected without one — so count them in the text.
116
124
  const source = await Bun.file(absolute).text();
117
- registerRoute({ file, config, suspenseBoundaries: countSuspense(source) });
125
+ // The page component comes from the same module as its config, resolved by render's own
126
+ // rule — the CLI does not decide which export is a page any more than it decides what a
127
+ // route is. A module with no component registers without one, and renders a bare shell.
128
+ const component = pageComponentOf(module);
129
+ registerRoute({
130
+ file,
131
+ config,
132
+ suspenseBoundaries: countSuspense(source),
133
+ ...(component === undefined ? {} : { component }),
134
+ });
118
135
  }
119
136
  registerActions(module);
120
137
  registerQueries(module);
package/src/bin.ts CHANGED
@@ -3,15 +3,16 @@
3
3
  // dispatch.ts, so the whole CLI is testable without spawning a process.
4
4
 
5
5
  import { dispatch } from './dispatch';
6
+ // The write itself is `write-line.ts`: `create-ultimate`'s entry point needs the identical one,
7
+ // and a second copy of a note about pipe truncation is a second copy that drifts.
8
+ import { writeLine } from './write-line';
6
9
 
7
10
  const code = await dispatch({
8
11
  argv: Bun.argv.slice(2),
9
12
  cwd: process.cwd(),
10
13
  env: Bun.env,
11
14
  bunVersion: Bun.version,
12
- write: (line) => {
13
- process.stdout.write(`${line}\n`);
14
- },
15
+ write: writeLine,
15
16
  });
16
17
 
17
18
  process.exit(code);
package/src/budgets.ts CHANGED
@@ -44,8 +44,44 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
44
44
  * finding, never a pass: a route that clears the gate without ever being weighed is exactly the
45
45
  * false green axiom 5 exists to prevent. Only a route that declares nothing is skipped.
46
46
  */
47
- export function checkBudgets(manifest: Manifest, stats: BuildStats): readonly Finding[] {
48
- const byPath = new Map(stats.routes.map((route) => [route.path, route]));
47
+ /**
48
+ * Two ways a budget goes unweighed, and they are not one instruction.
49
+ *
50
+ * `undefined` stats is "no build has ever run in this repo" — one command closes every route at
51
+ * once, and `.x/` is gitignored, so this is the state a fresh clone and a fresh scaffold are in.
52
+ * A stats file that exists and has no row for this route is the other thing entirely: a build DID
53
+ * run and could not weigh this one, which is `PrerenderReport.unmeasured`'s question and not a
54
+ * second build's. Reporting the first as the second is what sends a reader to re-run a build that
55
+ * already did everything it was going to do.
56
+ */
57
+ function unmeasuredFinding(url: string, declared: string, built: boolean): Finding {
58
+ return {
59
+ code: 'X_BUDGET_UNMEASURED',
60
+ cause: built
61
+ ? `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} has no row for it, so the build ran and could not weigh it`
62
+ : `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
63
+ // `--target static` is load-bearing and `x build` alone was a fix that changes nothing: the
64
+ // flag defaults to `docker`, and only the static target runs `apps/web/prerender.ts`, which is
65
+ // the one caller of `writeBuildStats`. When a build already ran, the second half is where the
66
+ // answer is — the report names every route it could not weigh, and why.
67
+ fix: built
68
+ ? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
69
+ : 'x build --target static --json && x verify --json',
70
+ docs: 'https://ultimate.dev/errors/X_BUDGET_UNMEASURED',
71
+ at: url,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * `undefined` stats means no build has run; `{ routes: [] }` means one ran and emitted nothing.
77
+ * The parameter is widened rather than defaulted, because collapsing the two here is exactly the
78
+ * distinction the finding above exists to make.
79
+ */
80
+ export function checkBudgets(
81
+ manifest: Manifest,
82
+ stats: BuildStats | undefined,
83
+ ): readonly Finding[] {
84
+ const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
49
85
  const findings: Finding[] = [];
50
86
  for (const route of manifest.routes) {
51
87
  const measured = byPath.get(route.url);
@@ -53,13 +89,7 @@ export function checkBudgets(manifest: Manifest, stats: BuildStats): readonly Fi
53
89
  const lcp = route.budget?.lcp;
54
90
  if (measured === undefined) {
55
91
  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
- });
92
+ findings.push(unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined));
63
93
  }
64
94
  continue;
65
95
  }
@@ -90,3 +120,98 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
90
120
  if (!existsSync(path)) return undefined;
91
121
  return (await Bun.file(path).json()) as BuildStats;
92
122
  }
123
+
124
+ const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
125
+ const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
126
+ const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
127
+
128
+ /**
129
+ * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
130
+ * `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
131
+ * `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
132
+ * Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
133
+ * and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
134
+ */
135
+ const carriesJson = (attrs: string): boolean => {
136
+ // Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
137
+ // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
138
+ // test alone charged an SEO structured-data block as executable JavaScript all over again.
139
+ const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
140
+ return type.trim().toLowerCase().endsWith('json');
141
+ };
142
+
143
+ /**
144
+ * An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
145
+ * as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
146
+ * never for the code that runtime boots. The entry attribute is that module URL, so it is read as
147
+ * exactly what it is: a file the browser will execute.
148
+ */
149
+ const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
150
+
151
+ /** One executable module the document names, and what it weighs on disk. */
152
+ export interface MeasuredEntry {
153
+ readonly url: string;
154
+ readonly bytes: number;
155
+ }
156
+
157
+ export interface MeasuredJs {
158
+ readonly jsBytes: number;
159
+ /** Every `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
160
+ readonly entries: readonly MeasuredEntry[];
161
+ }
162
+
163
+ /**
164
+ * What a rendered document actually makes the browser execute: the bytes of every inline script
165
+ * the parser will run, the size of every file a `src` points at, and the size of every island
166
+ * chunk it boots. A JSON-typed script is skipped — it is data the parser never runs. Measured
167
+ * from the emitted HTML rather than from the declared graph, because the graph is what a route
168
+ * *says* it ships and this gate exists to catch the case where those two disagree.
169
+ */
170
+ export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
171
+ let jsBytes = 0;
172
+ const entries: MeasuredEntry[] = [];
173
+ const weigh = async (url: string): Promise<void> => {
174
+ // Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
175
+ if (!url.startsWith('/')) return;
176
+ const file = Bun.file(join(out, url.slice(1)));
177
+ const bytes = (await file.exists()) ? file.size : 0;
178
+ entries.push({ url, bytes });
179
+ jsBytes += bytes;
180
+ };
181
+
182
+ for (const match of html.matchAll(SCRIPT_TAG)) {
183
+ const attrs = match.groups?.['attrs'] ?? '';
184
+ if (carriesJson(attrs)) continue;
185
+ const src = SRC_ATTR.exec(attrs)?.groups?.['src'];
186
+ if (src === undefined) {
187
+ jsBytes += Buffer.byteLength(match.groups?.['body'] ?? '', 'utf8');
188
+ continue;
189
+ }
190
+ await weigh(src);
191
+ }
192
+ // Deduped: two instances of one island are two wrappers and one chunk, and a browser that
193
+ // imports the same module twice fetches and executes it once.
194
+ const booted = new Set<string>();
195
+ for (const match of html.matchAll(ENTRY_ATTR)) {
196
+ const url = match.groups?.['url'];
197
+ if (url === undefined || booted.has(url)) continue;
198
+ booted.add(url);
199
+ await weigh(url);
200
+ }
201
+ return { jsBytes, entries };
202
+ }
203
+
204
+ /** The total alone, for a caller with nothing to say about which module was the heavy one. */
205
+ export async function measureJsBytes(html: string, out: string): Promise<number> {
206
+ return (await measureDocumentJs(html, out)).jsBytes;
207
+ }
208
+
209
+ /**
210
+ * The file `checkBudgets` reads. Written by the build and by nothing else — a stats file produced
211
+ * anywhere but from real output is the false green this gate exists to prevent.
212
+ */
213
+ export async function writeBuildStats(root: string, stats: BuildStats): Promise<string> {
214
+ const path = join(root, BUILD_STATS_FILE);
215
+ await Bun.write(path, `${JSON.stringify(stats, null, 2)}\n`);
216
+ return path;
217
+ }