@ultimat3/cli 22.15.0 → 23.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 (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -22,14 +22,23 @@ not exist — five of these had an empty column and were each measured green on
22
22
  | Rule | Detail | Refused by |
23
23
  |---|---|---|
24
24
  | One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
25
+ | Coverage | the unit suite covers 95% of the lines and functions of this app's own source — every file under \`apps/\` and \`packages/\`, one no test loads counted at 0%. A floor, not the goal: a test added to raise it is proven by mutation — break the source, watch it go red, restore | \`X_COVERAGE_BELOW_FLOOR\` on the \`unit\` step; \`coverage\` in \`x.verify.json\` states the floor, and it only rises |
25
26
  | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
26
27
  | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
27
28
  | Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
29
+ | Repos | a \`repo.ts\` reads through the typed handle, never a \`sql\` literal the handle can express | \`guards/repo-raw-sql.ts\` |
28
30
  | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
29
31
  | Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
30
32
  | Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
31
33
  | Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
32
34
  | Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
35
+ | Lengths | a length is a token or \`rem()\`, never a raw \`px\` | \`guards/raw-length.ts\` |
36
+ | Breakpoints | a viewport query names a breakpoint rung (\`respond-up\`, \`respond-down\`, \`respond-between\`), never a width | \`guards/raw-breakpoint.ts\` |
37
+ | Layers | a \`z-index\` is a named layer, never a number | \`guards/raw-z-index.ts\` |
38
+ | Shadows | a shadow comes off the shadow scale, never a hand-written offset and blur | \`guards/raw-shadow.ts\` |
39
+ | Timing | a duration and an easing curve come off the motion scale, never a literal | \`guards/raw-motion.ts\` |
40
+ | Classes | a class a component reads off its stylesheet is one that sheet compiles | \`guards/undefined-style-class.ts\` |
41
+ | Properties | a custom property a stylesheet reads is one the app declares | \`guards/undeclared-custom-property.ts\` |
33
42
  | Interaction | a click is answered by a control — never a \`<div onClick>\`, and never a \`role=\` where the native tag exists | \`guards/semantic-interactive.ts\` |
34
43
  | Focus | \`outline: none\` replaces the ring in the same rule or the one beside it, or it does not remove it | \`guards/focus-visible.ts\` |
35
44
  | Images | every image carries width + height or an aspect-ratio, and the priority one is never \`loading="lazy"\` | \`guards/image-dimensions.ts\` |
@@ -51,6 +60,23 @@ unit of review, and \`x verify\` refuses it. The one exemption is a file that is
51
60
  it has one job by construction, and its length tracks the API's size rather than its complexity;
52
61
  one statement of logic in such a file re-arms the ceiling on the same save.
53
62
 
63
+ Coverage counts execution, not validation: 100% is reachable with zero assertions, and a covered
64
+ branch whose test cannot fail is worse than an uncovered one, because it reads as done. An
65
+ \`exclude\` entry under \`coverage\` is for code a unit test cannot execute — an island's
66
+ browser-only mount, a container entry point — carries its \`why\`, and is printed by \`x doctor\`.
67
+
68
+ Every test the scaffold and the generators write is the idiom to copy. All of them run on the
69
+ \`unit\` step — in process, on the in-memory driver, with no database, browser or server:
70
+
71
+ | What | How its test runs it | Copy from |
72
+ |---|---|---|
73
+ | a page | \`renderRoute(page, { url, actor })\` — \`load\`, \`meta\` and the page on one resolution; assert on \`view.text\`, \`view.html\`, \`view.meta\`, \`view.islands\` | \`apps/web/app/dashboard/page.test.ts\` |
74
+ | a component | \`renderView(Component, props)\` | \`apps/web/shared/shell.test.ts\` |
75
+ | an island | \`mountIsland\`, once per state its \`*.island.states.ts\` declares | \`apps/web/shared/theme-toggle.island.test.ts\` |
76
+ | an action or a query | \`target.as(actor, input)\` — the refusal first, then what it answers | \`apps/web/api/health.test.ts\` |
77
+ | a job, a task, a backfill | \`test('…', async ({ runJobs }) => …)\` — a worker in this process; assert on \`trace.steps\` and each run's \`outcome\` | what \`x g job\` writes |
78
+ | a repo, a seed | as an actor, \`runWithContext(createContext({ actor }), …)\`, with \`driver.reset?.()\` after each | \`packages/db/src/seed.test.ts\` |
79
+
54
80
  Money is the one row with no guard, deliberately: a float has no static signature a text rule can
55
81
  see, and the type already fires — measured, \`price: 19.99\` in a seed is
56
82
  \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
@@ -86,6 +112,9 @@ ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in t
86
112
  - Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
87
113
  only place that list is stated.
88
114
  - Destructive DB work goes in a branch: \`x db branch create <name>\`, never the shared dev DB.
115
+ - \`x db gen\` also writes \`packages/db/schema/\` — the whole schema as SQL, one file per table.
116
+ Read it; never edit it. \`x verify\` fails with \`X_SCHEMA_DUMP_DRIFT\` when it is not what the
117
+ migrations produce, and \`x db gen\` rewrites it.
89
118
  - \`x doctor\` explains a broken environment and prints the fix command for every finding.
90
119
 
91
120
  \`.claude/\` holds the harness that reads this file: \`/feature\`, \`/planx\`, \`/verify\` and four
@@ -182,7 +211,12 @@ exec bunx x verify "$@"
182
211
 
183
212
  const composeDev = (
184
213
  app: NameSet,
185
- ): string => `# Optional: x dev needs none of this. Use it when you want the real Postgres/NATS/MinIO locally.
214
+ ): string => `# Optional: x dev needs none of this. Use it when you want a real Postgres, NATS and S3 locally:
215
+ #
216
+ # docker compose -f docker/docker-compose.dev.yml up -d
217
+ # DATABASE_URL=postgres://postgres:${app.kebab}@127.0.0.1:5432/${app.kebab} NATS_URL=nats://127.0.0.1:4222 \\
218
+ # S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=${app.kebab} S3_FORCE_PATH_STYLE=1 \\
219
+ # S3_ACCESS_KEY_ID=${app.kebab} S3_SECRET_ACCESS_KEY=${app.kebab}-dev x dev
186
220
  #
187
221
  # Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
188
222
  # as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
@@ -204,13 +238,24 @@ services:
204
238
  image: nats:2-alpine
205
239
  command: ['-js']
206
240
  ports: ['127.0.0.1:4222:4222']
241
+ # Versity S3 Gateway over a directory: a directory under /data is a bucket, so the volume mounted
242
+ # at /data/${app.kebab} IS the bucket — ready on the first \`up\`, no CreateBucket call. It checks
243
+ # the region a request was signed for, and an app that sets no S3_REGION signs for \`auto\` — so
244
+ # that is this gateway's region. A server for a laptop; production is any S3-compatible endpoint.
207
245
  s3:
208
- image: minio/minio
209
- command: ['server', '/data']
246
+ image: versity/versitygw:v1.8.0
210
247
  environment:
211
- MINIO_ROOT_USER: ${app.kebab}
212
- MINIO_ROOT_PASSWORD: ${app.kebab}-dev
248
+ VGW_BACKEND: posix
249
+ VGW_BACKEND_ARG: /data
250
+ VGW_PORT: ':9000'
251
+ VGW_REGION: auto
252
+ ROOT_ACCESS_KEY: ${app.kebab}
253
+ ROOT_SECRET_KEY: ${app.kebab}-dev
213
254
  ports: ['127.0.0.1:9000:9000']
255
+ volumes: ['s3bucket:/data/${app.kebab}']
256
+
257
+ volumes:
258
+ s3bucket:
214
259
  `;
215
260
 
216
261
  /** Docs, shims and container files for a new app, in the order a reader meets them. */
@@ -11,31 +11,74 @@
11
11
  // about: a control a keyboard cannot reach, a focus ring taken away and not replaced, an image with
12
12
  // no box, an animation the compositor cannot run, and an island nobody has seen fail. Each is
13
13
  // statically decidable from the file alone — the reason those five and not the many that are not.
14
+ //
15
+ // The seven after those are about the STYLESHEET, which Biome does not read at all: a length, a
16
+ // breakpoint, a layer, a shadow and a tempo off their scales, a class the sheet never compiled and
17
+ // a custom property nothing declares. Until they shipped, raw colour was the only one of the
18
+ // design system's rules a scaffolded app's gate held.
19
+ //
20
+ // And one is about DATA: a `repo.ts` reads through the typed handle. It ships because the generator
21
+ // used to emit string SQL, and every app that started from it copied the shape.
14
22
 
15
23
  import { animatedLayoutPropertyGuardFiles } from './guard-animated-layout-property';
16
24
  import { bareErrorGuardFiles } from './guard-bare-error';
17
25
  import { focusVisibleGuardFiles } from './guard-focus-visible';
18
26
  import { imageDimensionsGuardFiles } from './guard-image-dimensions';
19
27
  import { islandWithoutStatesGuardFiles } from './guard-island-without-states';
28
+ import { rawBreakpointGuardFiles } from './guard-raw-breakpoint';
20
29
  import { rawColourGuardFiles } from './guard-raw-colour';
30
+ import { rawLengthGuardFiles } from './guard-raw-length';
31
+ import { rawMotionGuardFiles } from './guard-raw-motion';
32
+ import { rawShadowGuardFiles } from './guard-raw-shadow';
33
+ import { rawZIndexGuardFiles } from './guard-raw-z-index';
34
+ import { repoRawSqlGuardFiles } from './guard-repo-raw-sql';
21
35
  import { semanticInteractiveGuardFiles } from './guard-semantic-interactive';
36
+ import { undeclaredCustomPropertyGuardFiles } from './guard-undeclared-custom-property';
37
+ import { undefinedStyleClassGuardFiles } from './guard-undefined-style-class';
22
38
  import { untranslatedStringGuardFiles } from './guard-untranslated-string';
23
39
  import { unzonedDateGuardFiles } from './guard-unzoned-date';
24
40
  import type { GeneratedFile } from './naming';
25
41
 
42
+ /**
43
+ * Every guard the framework ships, by the name its file carries — the ONE list. `x new` writes all
44
+ * of them (`scaffoldGuardFiles`), `x g guard <name>` writes the one it is asked for by name
45
+ * (`shippedGuardFiles`), and `x doctor` names the ones an app does not have. Sorted, so the three
46
+ * agree on an order. A new shipped guard is one module beside these and one row here.
47
+ */
48
+ const SHIPPED: readonly (readonly [string, () => readonly GeneratedFile[]])[] = [
49
+ ['animated-layout-property', animatedLayoutPropertyGuardFiles],
50
+ ['bare-error', bareErrorGuardFiles],
51
+ ['focus-visible', focusVisibleGuardFiles],
52
+ ['image-dimensions', imageDimensionsGuardFiles],
53
+ ['island-without-states', islandWithoutStatesGuardFiles],
54
+ ['raw-breakpoint', rawBreakpointGuardFiles],
55
+ ['raw-colour', rawColourGuardFiles],
56
+ ['raw-length', rawLengthGuardFiles],
57
+ ['raw-motion', rawMotionGuardFiles],
58
+ ['raw-shadow', rawShadowGuardFiles],
59
+ ['raw-z-index', rawZIndexGuardFiles],
60
+ ['repo-raw-sql', repoRawSqlGuardFiles],
61
+ ['semantic-interactive', semanticInteractiveGuardFiles],
62
+ ['undeclared-custom-property', undeclaredCustomPropertyGuardFiles],
63
+ ['undefined-style-class', undefinedStyleClassGuardFiles],
64
+ ['untranslated-string', untranslatedStringGuardFiles],
65
+ ['unzoned-date', unzonedDateGuardFiles],
66
+ ];
67
+
68
+ /** The names, in the order every list of them is shown. */
69
+ export const SHIPPED_GUARD_NAMES: readonly string[] = SHIPPED.map(([name]) => name);
70
+
71
+ /**
72
+ * The shipped guard of that name with its test, or `undefined` when the name is not one — which
73
+ * is an app's own convention, and `x g guard` then writes the blank template instead.
74
+ */
75
+ export const shippedGuardFiles = (name: string): readonly GeneratedFile[] | undefined =>
76
+ SHIPPED.find(([shipped]) => shipped === name)?.[1]();
77
+
26
78
  /**
27
79
  * Every guard `x new` ships, each with its test. One module per guard, because an app deletes a
28
80
  * rule it does not want by deleting one file — and a guard the app then writes for itself is
29
81
  * `x g guard <name>`, the same shape.
30
82
  */
31
- export const scaffoldGuardFiles = (): readonly GeneratedFile[] => [
32
- ...animatedLayoutPropertyGuardFiles(),
33
- ...bareErrorGuardFiles(),
34
- ...focusVisibleGuardFiles(),
35
- ...imageDimensionsGuardFiles(),
36
- ...islandWithoutStatesGuardFiles(),
37
- ...rawColourGuardFiles(),
38
- ...semanticInteractiveGuardFiles(),
39
- ...untranslatedStringGuardFiles(),
40
- ...unzonedDateGuardFiles(),
41
- ];
83
+ export const scaffoldGuardFiles = (): readonly GeneratedFile[] =>
84
+ SHIPPED.flatMap(([, files]) => files());
@@ -49,27 +49,29 @@ const httpConfigTest =
49
49
  // only thing that can go wrong is nobody importing the module — which is exactly how a shipped app
50
50
  // rendered every string as ⟦key⟧ for a whole release (issue #249), one seam along.
51
51
  import { configuredHttp, resetHttpConfig } from '@ultimat3/http';
52
- import { expect, unitTest } from '@ultimat3/testing';
52
+ import { afterAll, expect, unitTest } from '@ultimat3/testing';
53
53
  import './http';
54
54
 
55
+ // Read ONCE, at module scope: every case asserts on what the import registered, in any order.
56
+ const declared = configuredHttp();
57
+
55
58
  unitTest('importing the module IS the registration', () => {
56
- const declared = configuredHttp();
57
- expect(declared).toBeDefined();
58
59
  // The list is empty on a fresh scaffold and that is the shipped default; what is asserted is
59
60
  // that the KEY reaches the boot, so adding an origin to it takes effect.
60
61
  expect(declared?.cors?.origins).toEqual([]);
61
62
  });
62
63
 
63
64
  unitTest('the boot-owned keys are absent — the boot measures them, an app can only guess', () => {
64
- const declared = configuredHttp() ?? {};
65
+ expect(declared).toBeDefined();
65
66
  for (const key of ['port', 'hostname', 'dev', 'buildId', 'signInPath']) {
66
- expect({ key, declared: Object.hasOwn(declared, key) }).toEqual({ key, declared: false });
67
+ expect({ key, declared: Object.hasOwn(declared ?? {}, key) }).toEqual({ key, declared: false });
67
68
  }
68
69
  });
69
70
 
70
71
  // The registration is process-global, so a suite that left it set would hand the next file this
71
- // app's config. \`resetHttpConfig()\` is the seam; this is the one place it is called.
72
- unitTest('and it is resettable, so no test file inherits the server config of another', () => {
72
+ // app's config. \`resetHttpConfig()\` is the seam, and this is the one place it is called — after
73
+ // the cases, so none of them depends on running before it.
74
+ afterAll(() => {
73
75
  resetHttpConfig();
74
76
  expect(configuredHttp()).toBeUndefined();
75
77
  });
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
8
8
  import { REQUIRED_BUN } from '../app-root';
9
+ import type { CoverageExclude } from '../coverage-floor';
10
+ import { COVERAGE_BAR } from '../coverage-floor';
9
11
  import { VERIFY_FLOOR_FILE } from '../verify-floor';
10
12
  import type { VerifyStepName } from '../verify-step';
11
13
  import type { GeneratedFile, NameSet } from './naming';
@@ -187,7 +189,7 @@ const appConfig = (
187
189
  // a missing value fails the boot with the exact command that fixes it, never at the first request.
188
190
  // A named export, never a default: the CLI and the runtime both import \`config\` by name.
189
191
  import type { EnvSchema } from '@ultimat3/core';
190
- import { defineConfig, defineEnv } from '@ultimat3/core';
192
+ import { defineConfig, defineEnv, defineMeasurementActor, userActor } from '@ultimat3/core';
191
193
 
192
194
  ${envDeclaration()}
193
195
 
@@ -227,6 +229,18 @@ export const config = defineConfig({
227
229
  theme: { defaultMode: 'dark' },
228
230
  ai: { mcp: { expose: true, path: '/mcp' } },
229
231
  });
232
+
233
+ /**
234
+ * Who \`x build\`'s budget pass renders \`app/\` as. A page that reads rows reads its actor's org
235
+ * through the typed handle, which refuses an actor with none (X_TENANCY_ACTOR_ORG_REQUIRED) — and
236
+ * the framework's default measurement actor is a \`service\` with no org, so such a route is never
237
+ * weighed (X_BUDGET_UNMEASURED). A member of the demo org is a declared viewer: no row has to
238
+ * exist. Loaded lazily, so this file's import graph stays the config's own.
239
+ */
240
+ defineMeasurementActor(async () => {
241
+ const { DEMO_ORG_ID } = await import('./apps/web/shared/demo-org');
242
+ return userActor({ id: 'x-build-measure', orgId: DEMO_ORG_ID, roles: ['member'] });
243
+ });
230
244
  `;
231
245
 
232
246
  // `biome.json` is strict JSON — Biome's own parser rejects a `//` comment in it, which made every
@@ -330,8 +344,44 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
330
344
  /** The two suites only the example slice writes a file for — a floor step with no file is red. */
331
345
  const EXAMPLE_FLOOR: readonly VerifyStepName[] = ['live', 'job'];
332
346
 
347
+ /**
348
+ * What a unit test cannot execute in a scaffolded app, each with its reason — `x doctor` prints
349
+ * them. Two kinds and no more: a file here is a file the floor does not see, so the list is not
350
+ * a place to put code that merely has no test yet.
351
+ */
352
+ export const SCAFFOLD_COVERAGE_EXCLUDE: readonly CoverageExclude[] = [
353
+ {
354
+ glob: 'apps/**/*.island.tsx',
355
+ why: 'an island runs in a browser: its test mounts a BUILT chunk (mountIsland), and Bun cannot map that chunk back to this source — its states are held by guards/island-without-states.ts and photographed by x shot --island',
356
+ },
357
+ {
358
+ glob: 'apps/*/server.ts',
359
+ why: 'the container entry point: it boots the roles and lives as long as the process — bin/dev and the image exercise it',
360
+ },
361
+ {
362
+ glob: 'apps/*/prerender.ts',
363
+ why: "the static build's entry point: x build --target static runs it, and the budgets step weighs what it writes",
364
+ },
365
+ ];
366
+
367
+ /**
368
+ * The coverage floor every scaffolded app starts on: the bar the framework holds its own packages
369
+ * to (`COVERAGE_BAR`), not a softer one for apps. `scaffold-smoke` runs the app's own `x verify`,
370
+ * so the scaffold is green at it on what it generates or the job is red.
371
+ */
333
372
  const verifyFloor = (example: boolean): string =>
334
- `${JSON.stringify({ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR }, null, 2)}\n`;
373
+ `${JSON.stringify(
374
+ {
375
+ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR,
376
+ coverage: {
377
+ lines: COVERAGE_BAR,
378
+ funcs: COVERAGE_BAR,
379
+ exclude: SCAFFOLD_COVERAGE_EXCLUDE,
380
+ },
381
+ },
382
+ null,
383
+ 2,
384
+ )}\n`;
335
385
 
336
386
  const bunfig = (): string => `[test]
337
387
  root = "."
@@ -6,18 +6,34 @@
6
6
  import type { GeneratedFile } from './naming';
7
7
  import { wrapList } from './wrap';
8
8
 
9
- /**
10
- * A role's grants as Biome prints them. The example slice's \`post:read\`/\`post:write\` ride along
11
- * with it: \`x g resource post\` would have granted them, and the scaffold writes that result.
12
- */
13
- const grants = (base: string, example: string | undefined): string =>
9
+ /** A role's grants as Biome prints them: one line when it fits, one grant per line when not. */
10
+ const grants = (list: readonly string[]): string =>
14
11
  wrapList(
15
12
  ' ',
16
13
  'grants: [',
17
- [base, ...(example === undefined ? [] : [example])].map((grant) => `'${grant}'`),
14
+ list.map((grant) => `'${grant}'`),
18
15
  '],',
19
16
  );
20
17
 
18
+ /** The three gates `@ultimat3/admin` asks before any per-table one — `ADMIN_OPERATION_RULES`. */
19
+ const ADMIN_GATES = ['admin:read', 'admin:write', 'admin:destroy'] as const;
20
+
21
+ /**
22
+ * What the two built-in screens with no table behind them ask beside `admin:read`: `/admin/jobs`
23
+ * and `/admin/audit`. Held by the role that runs the admin, or both links are a 403.
24
+ */
25
+ const ADMIN_SCREENS = ['job:read', 'audit:read'] as const;
26
+
27
+ /**
28
+ * What the admin asks for one table on the handle: `<table>:read|write|delete`. The example slice
29
+ * adds `posts`, so the scaffold writes the grants `x g resource post` would have — the same three
30
+ * `generate-grants.ts` adds for every entity generated later.
31
+ */
32
+ const tableGrants = (table: string): readonly string[] =>
33
+ ['read', 'write', 'delete'].map((verb) => `${table}:${verb}`);
34
+
35
+ const EXAMPLE_TABLE = 'posts';
36
+
21
37
  const rolesSource = (
22
38
  example: boolean,
23
39
  ): string => `// Who holds which permission, for the whole app. Roles are sugar: every one expands to a flat
@@ -33,6 +49,12 @@ const rolesSource = (
33
49
  // \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\` and grants them below —
34
50
  // read to member, write to admin. A permission no role holds is one no actor can ever exercise,
35
51
  // and \`x verify\`'s policy step refuses it (X_PERMISSION_UNGRANTED).
52
+ //
53
+ // The admin at /admin asks TWO permissions per operation: its own gate (\`admin:read\` to look,
54
+ // \`admin:write\` to create or edit, \`admin:destroy\` to delete) and the table's
55
+ // (\`<table>:read|write|delete\`); its jobs and audit screens ask \`job:read\` and \`audit:read\`. \`x g entity\` and \`x g resource\` declare and grant a new table's
56
+ // three to \`admin\` here. A view-only operator is a role holding \`admin:read\` and each
57
+ // \`<table>:read\` and nothing else: every write is refused by the same decision that hid its button.
36
58
 
37
59
  import { definePermissions, defineRoles } from '@ultimat3/policy';
38
60
 
@@ -42,16 +64,30 @@ import { definePermissions, defineRoles } from '@ultimat3/policy';
42
64
  // in silence while nothing declared it, so every scaffolded app answered HTTP 500 on /dashboard
43
65
  // and /admin from its first \`x dev\`, under a green gate. A permission a role grants and a
44
66
  // permission a route requires both belong here.
45
- export const appPermissions = definePermissions(['admin:read', 'dashboard:read']);
67
+ ${wrapList(
68
+ '',
69
+ 'export const appPermissions = definePermissions([',
70
+ [
71
+ ...ADMIN_GATES,
72
+ ...ADMIN_SCREENS,
73
+ 'dashboard:read',
74
+ ...(example ? tableGrants(EXAMPLE_TABLE) : []),
75
+ ].map((permission) => `'${permission}'`),
76
+ ']);',
77
+ )}
46
78
 
47
79
  export const roles = defineRoles({
48
80
  member: {
49
81
  description: 'Signed in. Reads the app surface.',
50
- ${grants('dashboard:read', example ? 'post:read' : undefined)}
82
+ ${grants(['dashboard:read', ...(example ? ['post:read'] : [])])}
51
83
  },
52
84
  admin: {
53
85
  description: 'Runs the app: the /admin surface, plus everything a member may do.',
54
- ${grants('admin:read', example ? 'post:write' : undefined)}
86
+ ${grants([
87
+ ...ADMIN_GATES,
88
+ ...ADMIN_SCREENS,
89
+ ...(example ? ['post:write', ...tableGrants(EXAMPLE_TABLE)] : []),
90
+ ])}
55
91
  inherits: ['member'],
56
92
  },
57
93
  });
@@ -85,6 +121,17 @@ unitTest('admin inherits every member grant and adds its own', () => {
85
121
  for (const permission of member) expect(admin).toContain(permission);
86
122
  });
87
123
 
124
+ // \`admin:read\` alone looks; a write needs \`admin:write\` and a delete \`admin:destroy\`. The admin
125
+ // role holds all three, and a member none — so "view-only" is a role, never a mode.
126
+ unitTest('the admin role holds all three admin gates, and a member none of them', () => {
127
+ const admin = expandRoles(['admin'], roles);
128
+ const member = expandRoles(['member'], roles);
129
+ for (const gate of ['admin:read', 'admin:write', 'admin:destroy']) {
130
+ expect(admin).toContain(gate);
131
+ expect(member).not.toContain(gate);
132
+ }
133
+ });
134
+
88
135
  unitTest('a role nobody declared grants nothing', () => {
89
136
  expect(expandRoles(['visitor'], roles)).toEqual([]);
90
137
  });
@@ -183,7 +183,7 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
183
183
  block-size: tokens.space(2);
184
184
  border-radius: tokens.radius('full');
185
185
  background: tokens.role('success');
186
- animation: pulse 2.4s tokens.easing('in-out') infinite;
186
+ animation: pulse calc(#{tokens.duration('slower')} * 4) tokens.easing('in-out') infinite;
187
187
  }
188
188
 
189
189
  // Opacity only — the one property family the motion guard admits, and the global reduced-motion
@@ -245,45 +245,25 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
245
245
  `;
246
246
 
247
247
  const shellTest =
248
- (): string => `// The frame, rendered the way a route renders it: through the framework's server JSX factory,
249
- // with the app's own catalog registered by the import. What can go wrong is structural — the
250
- // current page losing its \`aria-current\`, the page's actions landing outside the banner — and
251
- // both are invisible to a typecheck.
252
- import { h, type JsxComponent } from '@ultimat3/render';
253
- import { renderToHtml } from '@ultimat3/render/server';
254
- import { expect, unitTest } from '@ultimat3/testing';
255
-
256
- // A DYNAMIC import, after the static ones above have run: importing \`@ultimat3/render\` is what
257
- // installs the loader that compiles this app's \`.tsx\` to the framework's JSX factory, and a
258
- // plugin only reaches modules loaded after it. Imported statically, \`shell.tsx\` is transpiled
259
- // ahead of that install, to \`React.createElement\` against a global that does not exist — the
260
- // route modules never meet this because \`x dev\` loads them after the framework.
261
- //
262
- // \`h\` is typed for the framework's own components; \`Shell\` carries Solid's JSX types. The cast
263
- // is the seam itself — the assertions below are what check it at runtime.
264
- const shell = (await import('./shell')).Shell as unknown as JsxComponent;
248
+ (): string => `// The frame, rendered the way a route renders it, with the app's own catalog registered by the
249
+ // import. What can go wrong is structural — the current page losing its \`aria-current\`, the
250
+ // page's actions landing outside the banner — and both are invisible to a typecheck.
251
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
252
+ import { Shell } from './shell';
265
253
 
266
254
  unitTest('the shell marks the current destination and keeps the page in <main>', async () => {
267
- const html = await renderToHtml(
268
- h(shell, { nav: 'dashboard', children: h('p', { 'data-role': 'page' }, 'body') }),
269
- );
270
- expect(html).toContain('aria-current="page"');
271
- expect(html).toContain('href="/dashboard"');
255
+ const view = await renderView(Shell, { nav: 'dashboard', children: 'the page body' });
256
+ expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
272
257
  // Landmarks come from AppShell: one banner, one navigation, one main, one contentinfo.
273
- expect(html.match(/<main\\b/g)?.length).toBe(1);
274
- expect(html.match(/<nav\\b/g)?.length).toBe(1);
275
- expect(html).toMatch(/<main[^>]*>[\\s\\S]*data-role="page"[\\s\\S]*<\\/main>/);
258
+ expect(view.html.match(/<main\\b/g)).toHaveLength(1);
259
+ expect(view.html.match(/<nav\\b/g)).toHaveLength(1);
260
+ expect(view.html).toMatch(/<main[^>]*>the page body<\\/main>/);
276
261
  });
277
262
 
278
263
  unitTest('no nav marks nothing current, and the actions land in the header', async () => {
279
- const html = await renderToHtml(
280
- h(shell, {
281
- actions: h('button', { type: 'button', 'data-role': 'action' }, 'act'),
282
- children: h('p', null, 'body'),
283
- }),
284
- );
285
- expect(html).not.toContain('aria-current="page"');
286
- expect(html).toMatch(/<header[^>]*>[\\s\\S]*data-role="action"[\\s\\S]*<\\/header>/);
264
+ const view = await renderView(Shell, { actions: 'the page actions', children: 'body' });
265
+ expect(view.html).not.toContain('aria-current="page"');
266
+ expect(view.html).toMatch(/<header[^>]*>[\\s\\S]*the page actions[\\s\\S]*<\\/header>/);
287
267
  });
288
268
  `;
289
269
 
@@ -379,20 +359,10 @@ const toggleTest =
379
359
 
380
360
  import { join } from 'node:path';
381
361
  import { buildIslands } from '@ultimat3/cli';
382
- import {
383
- afterAll,
384
- beforeAll,
385
- describe,
386
- expect,
387
- type MountedIsland,
388
- mountIsland,
389
- test,
390
- } from '@ultimat3/testing';
362
+ import { describeIslandState, expect, test } from '@ultimat3/testing';
391
363
  import { THEME_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
392
364
  import { bootedEnv } from './theme-toggle.island';
393
-
394
- const APP_ROOT = join(import.meta.dir, ${upToAppRoot(SHELL_DIR)});
395
- const ISLAND = '${SHELL_DIR}/theme-toggle.island.tsx';
365
+ import { themeToggleStates } from './theme-toggle.island.states';
396
366
 
397
367
  /** What the island reads through \`browserThemeEnv\`: storage and the OS query, both fakes. */
398
368
  const stored = new Map<string, string>();
@@ -407,42 +377,38 @@ const matchMedia = (): Record<string, unknown> => ({
407
377
  removeEventListener: () => {},
408
378
  });
409
379
 
410
- let mounted: MountedIsland;
411
-
412
- beforeAll(async () => {
413
- mounted = await mountIsland({
414
- build: buildIslands,
415
- root: APP_ROOT,
416
- file: ISLAND,
417
- props: { locale: 'en' },
418
- shell: '<button type="button">theme</button>',
419
- globals: { localStorage, matchMedia },
420
- });
421
- }, 60_000);
422
-
423
- afterAll(() => {
424
- mounted?.[Symbol.dispose]();
425
- });
380
+ // The state \`x shot --island theme-toggle\` photographs is the state this mounts: one manifest,
381
+ // so the picture and the assertion cannot be of two different components.
382
+ const island = {
383
+ build: buildIslands,
384
+ root: join(import.meta.dir, ${upToAppRoot(SHELL_DIR)}),
385
+ shell: '<button type="button">theme</button>',
386
+ globals: { localStorage, matchMedia },
387
+ };
426
388
 
427
- describe('the theme toggle island', () => {
428
- test('mount renders the catalog control over the server shell', () => {
429
- expect(mounted.find('button')).not.toBeNull();
430
- expect(mounted.code).not.toMatch(/\\bReact\\b/);
389
+ describeIslandState(themeToggleStates, 'default', island, (mounted) => {
390
+ test('the default state renders the catalog control over the server shell', () => {
391
+ // \`aria-pressed\` is the live control's: the shell's own button carries none, so a mount that
392
+ // rendered nothing leaves this selector with no match.
393
+ expect(mounted().find('button[aria-pressed]')).not.toBeNull();
394
+ expect(mounted().code).not.toMatch(/\\bReact\\b/);
395
+ // The state's own prop reached the island: the provider stamps the locale it was handed.
396
+ expect(mounted().documentElement.getAttribute('lang')).toBe('en');
431
397
  });
432
398
 
433
399
  test('a click stores the opposite theme and applies it to <html>', () => {
434
400
  // No stamp on this document, so the island booted light; the click flips it. \`false\` means
435
401
  // no handler ran — an onClick that never reached the DOM looks identical to a selector typo.
436
- expect(mounted.fire('button', 'click')).toBe(true);
402
+ expect(mounted().fire('button', 'click')).toBe(true);
437
403
  expect(stored.get(THEME_STORAGE_KEY)).toBe('dark');
438
- expect(mounted.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
404
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
439
405
  });
440
406
 
441
407
  test('the env the island builds starts from the theme stamped on <html>', () => {
442
408
  // The fake document is still installed here, so this reads exactly what \`mount\` read.
443
- mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
409
+ mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
444
410
  expect(bootedEnv().prefersDark()).toBe(true);
445
- mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
411
+ mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
446
412
  expect(bootedEnv().prefersDark()).toBe(false);
447
413
  });
448
414
  });