@ultimat3/cli 22.14.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 (174) hide show
  1. package/CLAUDE.md +25 -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 +35 -3
  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-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -0,0 +1,122 @@
1
+ // The schema dump, regenerated on behalf of a command: `x db gen` and `x db migrate` both end by
2
+ // making `packages/db/schema/` equal to what the migrations produce. Split from
3
+ // `db-schema-dump.ts`, which replays and writes; this decides what a command REPORTS when the dump
4
+ // was written, was already right, was not owed, or could not be produced — and holds the two
5
+ // findings that are about the dump as a whole rather than about one file in it.
6
+
7
+ import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
8
+ import type { Migration } from '@ultimat3/db';
9
+ import { PGLITE_MISSING } from '@ultimat3/db';
10
+ import type { CatalogDescription } from '@ultimat3/db/schema-dump';
11
+ import {
12
+ hasSchemaDump,
13
+ type ReplayedSchema,
14
+ replayFor,
15
+ SCHEMA_DUMP_DIR,
16
+ type SchemaDumpWrite,
17
+ writeSchemaDump,
18
+ } from './db-schema-dump';
19
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
20
+ import type { Finding, JsonValue } from './output';
21
+ import { findingFrom, isUltimateErrorShape } from './output';
22
+
23
+ /**
24
+ * The migrations would not replay, so there is no dump to write and nothing to compare one with.
25
+ * A framework error keeps its own cause; the fix is replaced, because the engine's own (`set
26
+ * DATABASE_URL …`) answers a question nobody asked — this database is a scratch one booted here.
27
+ *
28
+ * Two causes are not about the migrations at all, and each keeps the fix that repairs it: a
29
+ * refusal this pipeline raised itself (`X_SCHEMA_DUMP_DRIFT` — no server for an extension the
30
+ * embedded database cannot link) arrives whole, and a missing optional peer gets the install,
31
+ * because `x db migrate` would succeed and teach nothing.
32
+ */
33
+ export function replayFailure(error: unknown): Finding {
34
+ if (isUltimateErrorShape(error) && error.code === 'X_SCHEMA_DUMP_DRIFT') {
35
+ return { ...findingFrom(error), docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
36
+ }
37
+ const detail = isUltimateErrorShape(error) ? findingFrom(error).cause : renderThrowable(error);
38
+ const missing = detail.startsWith(PGLITE_MISSING);
39
+ return {
40
+ code: 'X_SCHEMA_DUMP_DRIFT',
41
+ cause: missing
42
+ ? `the schema dump is replayed on the embedded database and ${detail}`
43
+ : `the migrations do not replay on the scratch database, so the schema dump cannot be produced: ${detail}`,
44
+ fix: missing
45
+ ? // A literal, as `@ultimat3/db`'s own `PGLITE_FIX` is: a `fix:` splices nothing into its
46
+ // command position, and `db-schema-refresh.test.ts` holds it equal to `PGLITE_PACKAGE`.
47
+ 'bun add -d @electric-sql/pglite # then: x db gen'
48
+ : 'x db migrate --json # applies the same migrations to the dev database; a statement refused there too is the one to repair — then: x db gen',
49
+ docs: ERROR_DOCS_URL,
50
+ at: missing ? SCHEMA_DUMP_DIR : MIGRATIONS_DIR,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The same finding, for a run of `x db gen` that DID write its migration before the dump failed.
56
+ * The exit code is 1 and the migration is on disk, and a reader who sees only the first fact
57
+ * generates a second migration for a schema that is already recorded. So the cause leads with
58
+ * the file that exists, and says what the next `x db gen` will and will not do.
59
+ */
60
+ export const afterWrittenMigration = (finding: Finding, id: string): Finding => ({
61
+ ...finding,
62
+ cause: `migration ${id} WAS written to ${MIGRATIONS_DIR} and is not to be generated again; only the schema dump failed, and once that is repaired a bare x db gen writes the dump and no second migration — ${finding.cause}`,
63
+ });
64
+
65
+ /** An app with a migration and no dump directory: one finding, and no database booted to say so. */
66
+ export const dumpNeverGenerated = (): Finding => ({
67
+ code: 'X_SCHEMA_DUMP_DRIFT',
68
+ cause: `${MIGRATIONS_DIR} holds migrations and ${SCHEMA_DUMP_DIR} does not exist: the schema dump was never generated`,
69
+ fix: 'x db gen',
70
+ docs: ERROR_DOCS_URL,
71
+ at: SCHEMA_DUMP_DIR,
72
+ });
73
+
74
+ export interface SchemaDumpRefresh extends SchemaDumpWrite {
75
+ /**
76
+ * `skipped` — no migration and no dump, so nothing is owed and no database is booted.
77
+ * `failed` — the migrations did not replay; `finding` says why, and the dump on disk is left
78
+ * exactly as it was.
79
+ */
80
+ readonly status: 'written' | 'unchanged' | 'skipped' | 'failed';
81
+ readonly finding?: Finding | undefined;
82
+ /** The replayed catalog, for a caller that compares a live database against it. */
83
+ readonly catalog?: CatalogDescription | undefined;
84
+ }
85
+
86
+ const NOTHING: SchemaDumpWrite = { written: [], removed: [], total: 0 };
87
+
88
+ /**
89
+ * Regenerate the dump after `x db gen` or `x db migrate`. Never throws: a replay that fails is
90
+ * reported as the finding it is, after the migration the command was asked for is already written.
91
+ */
92
+ export async function refreshSchemaDump(
93
+ root: string,
94
+ env: Readonly<Record<string, string | undefined>> = Bun.env,
95
+ // Injected so the reporting rules are testable without booting a database per case.
96
+ replay: (migrations: readonly Migration[]) => Promise<ReplayedSchema> = replayFor(
97
+ root,
98
+ env,
99
+ false,
100
+ ),
101
+ ): Promise<SchemaDumpRefresh> {
102
+ const migrations = await readMigrations(root);
103
+ if (migrations.length === 0 && !hasSchemaDump(root)) return { ...NOTHING, status: 'skipped' };
104
+ let replayed: ReplayedSchema;
105
+ try {
106
+ replayed = await replay(migrations);
107
+ } catch (error) {
108
+ return { ...NOTHING, status: 'failed', finding: replayFailure(error) };
109
+ }
110
+ const write = await writeSchemaDump(root, replayed.files);
111
+ const changed = write.written.length + write.removed.length > 0;
112
+ return { ...write, status: changed ? 'written' : 'unchanged', catalog: replayed.catalog };
113
+ }
114
+
115
+ /** The `--json` shape both commands report under `data.schemaDump`. */
116
+ export const schemaDumpJson = (refresh: SchemaDumpRefresh): JsonValue => ({
117
+ directory: SCHEMA_DUMP_DIR,
118
+ status: refresh.status,
119
+ files: refresh.total,
120
+ written: [...refresh.written],
121
+ removed: [...refresh.removed],
122
+ });
@@ -55,8 +55,8 @@ export interface DevDashboardInput {
55
55
 
56
56
  /**
57
57
  * Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
58
- * reached. A second gate here would be a second authz: two places to update when `x db psql
59
- * --write` changes what is allowed, and one of them would eventually disagree.
58
+ * reached. A second gate here would be a second authz: two places to update when what the panel
59
+ * allows changes, and one of them would eventually disagree.
60
60
  */
61
61
  async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
62
62
  const started = performance.now();
@@ -17,8 +17,11 @@ import { islandHarnessRoutes } from './island-harness-route';
17
17
  import { islandRoutes } from './island-routes';
18
18
  import { loadIslandStates } from './island-states-load';
19
19
  import { loadNavigation, pageNavigation } from './page-navigation';
20
+ import type { PageSpeculation } from './page-speculation';
21
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
20
22
  import { pageSync } from './page-sync';
21
23
  import { loadPwaArtifacts } from './pwa-artifacts';
24
+ import { adminMountRoutes } from './runtime-admin';
22
25
  import { assetRoutes } from './runtime-assets';
23
26
  import { appRoutes } from './runtime-render';
24
27
  import { servedStorage, storageRoutes } from './runtime-storage';
@@ -56,6 +59,8 @@ export interface DevRouteTable {
56
59
  readonly routes: readonly Route[];
57
60
  /** The theme boot, whose `cspSource` the web role admits. */
58
61
  readonly theme: ThemeBoot;
62
+ /** The speculation rules, admitted the same way — `undefined` when no document carries any. */
63
+ readonly speculation: PageSpeculation | undefined;
59
64
  /** The app's own error pages' inline styles, admitted the same way. */
60
65
  readonly errorStyles: readonly string[];
61
66
  /** Where the app's default MCP endpoint was mounted, or `null`. */
@@ -75,11 +80,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
75
80
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
76
81
  const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
77
82
  // The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
78
- const navigation = await pageNavigation(
79
- input.root,
80
- await loadNavigation(input.root),
81
- input.buildId,
82
- );
83
+ const declared = await loadNavigation(input.root);
84
+ const navigation = await pageNavigation(input.root, declared, input.buildId);
85
+ // The browser's own prefetch, from the table as it stands at boot: a page added by a later save
86
+ // joins the rules on the next `x dev` — its links are a full load until then, never a wrong one.
87
+ const speculation = pageSpeculation({
88
+ config: await loadSpeculation(input.root),
89
+ client: declared.surfaces,
90
+ });
83
91
  const errorStyles = await errorPageStyleSources(input.root);
84
92
  // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
85
93
  // changes under a page it controls is the update path, and one per keystroke exercises it per save.
@@ -143,6 +151,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
143
151
  ...sync.routes,
144
152
  ...navigation.routes,
145
153
  ...pagePostRoutes(),
154
+ // The app's generated admin, when it declared one (`defineAdmin`): every screen under its base
155
+ // path, served through the same document builder as the pages below. Nothing for an app with
156
+ // no admin — the registry is asked, the package is not imported.
157
+ ...adminMountRoutes({
158
+ buildId: input.buildId,
159
+ themeHead: theme.head,
160
+ ...(origin === undefined ? {} : { origin }),
161
+ }),
146
162
  ...appRoutes({
147
163
  buildId: input.buildId,
148
164
  ...(input.isr === undefined ? {} : { isr: input.isr }),
@@ -151,6 +167,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
151
167
  persisted: sync.persisted,
152
168
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
153
169
  themeHead: theme.head,
170
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
154
171
  ...(origin === undefined ? {} : { origin }),
155
172
  ...(pwa === undefined
156
173
  ? {}
@@ -158,5 +175,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
158
175
  }),
159
176
  ];
160
177
 
161
- return { routes, theme, errorStyles, mcpPath: mcpMount.path, mcpPaths: mcpMount.paths };
178
+ return {
179
+ routes,
180
+ theme,
181
+ speculation,
182
+ errorStyles,
183
+ mcpPath: mcpMount.path,
184
+ mcpPaths: mcpMount.paths,
185
+ };
162
186
  }
@@ -0,0 +1,39 @@
1
+ // `x doctor`'s coverage listing: every file the app's coverage floor does not see, and the reason
2
+ // `x.verify.json` gives for each. A LISTING, never a finding: an `exclude` with its `why` is a
3
+ // decision the app made in a committed file — printing it is what keeps the decision in view, and
4
+ // a red `x doctor` over it would be the framework overruling what it asked to be written down.
5
+
6
+ import type { CoverageExclude } from './coverage-floor';
7
+ import { msg } from './messages';
8
+ import type { CommandResult, JsonValue } from './output';
9
+ import { readVerifyFloor } from './verify-floor';
10
+
11
+ /** The I/O half: the excludes this app's `x.verify.json` states. None when it states no floor. */
12
+ export async function coverageExcludesProbe(root: string): Promise<readonly CoverageExclude[]> {
13
+ return (await readVerifyFloor(root))?.coverage?.exclude ?? [];
14
+ }
15
+
16
+ /**
17
+ * The listing, on a result `x doctor` already decided: `data.coverage.exclude` for a machine, one
18
+ * line per entry for a person. `ok`, the summary and the findings are returned as they came.
19
+ */
20
+ export function withCoverageExcludes(
21
+ result: CommandResult,
22
+ exclude: readonly CoverageExclude[],
23
+ ): CommandResult {
24
+ const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
25
+ const listed: JsonValue = exclude.map(({ glob, why }) => ({ glob, why }));
26
+ return {
27
+ ...result,
28
+ data: { ...(data as Record<string, JsonValue>), coverage: { exclude: listed } },
29
+ ...(exclude.length === 0
30
+ ? {}
31
+ : {
32
+ lines: [
33
+ ...(result.lines ?? []),
34
+ msg('cli.doctor.coverageExclude', { count: exclude.length }),
35
+ ...exclude.map(({ glob, why }) => ` ${glob} — ${why}`),
36
+ ],
37
+ }),
38
+ };
39
+ }
@@ -0,0 +1,54 @@
1
+ // `x doctor`'s guard listing: the guards the framework ships that this app's `guards/` does not
2
+ // hold, each with the command that adds it. A LISTING, never a finding: every shipped guard says
3
+ // "delete this file to drop the rule", so an app without one may have decided — and a red
4
+ // `x doctor` over a decision would be the framework overruling the convention it handed over.
5
+
6
+ import { GUARD_DIR, guardPaths } from './guards';
7
+ import { msg } from './messages';
8
+ import type { CommandResult, JsonValue } from './output';
9
+ import { SHIPPED_GUARD_NAMES } from './templates/scaffold-guards';
10
+
11
+ export interface MissingGuard {
12
+ readonly name: string;
13
+ /** The command that writes it, with its test. Then `x verify` says what it finds. */
14
+ readonly add: string;
15
+ }
16
+
17
+ /** The rule, pure: which shipped names have no file among the guards an app holds. */
18
+ export function missingShippedGuards(present: readonly string[]): readonly MissingGuard[] {
19
+ const held = new Set(present.map((path) => path.replace(/\.tsx?$/, '')));
20
+ return SHIPPED_GUARD_NAMES.filter((name) => !held.has(`${GUARD_DIR}/${name}`)).map((name) => ({
21
+ name,
22
+ add: `x g guard ${name}`,
23
+ }));
24
+ }
25
+
26
+ /** The I/O half: the same enumeration the `boundaries` step runs, so the two cannot disagree. */
27
+ export async function shippedGuardsProbe(root: string): Promise<readonly MissingGuard[]> {
28
+ return missingShippedGuards(await guardPaths(root));
29
+ }
30
+
31
+ /**
32
+ * The listing, on a result `x doctor` already decided: `data.guards.missing` for a machine, one
33
+ * line per command for a person. `ok`, the summary and the findings are returned as they came.
34
+ */
35
+ export function withGuardListing(
36
+ result: CommandResult,
37
+ missing: readonly MissingGuard[],
38
+ ): CommandResult {
39
+ const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
40
+ const listed: JsonValue = missing.map(({ name, add }) => ({ name, add }));
41
+ return {
42
+ ...result,
43
+ data: { ...(data as Record<string, JsonValue>), guards: { missing: listed } },
44
+ ...(missing.length === 0
45
+ ? {}
46
+ : {
47
+ lines: [
48
+ ...(result.lines ?? []),
49
+ msg('cli.doctor.guards', { count: missing.length }),
50
+ ...missing.map(({ add }) => ` ${add}`),
51
+ ],
52
+ }),
53
+ };
54
+ }
@@ -0,0 +1,132 @@
1
+ // `x doctor`'s sealed-column checks: the columns the app declared `.sealed()`, against the master
2
+ // keys this environment declares. Its own file for `doctor-offline.ts`'s reason — answering needs
3
+ // the app's entities and the committed secrets, and `cmd-doctor.ts`'s job is the probe. The split
4
+ // is the same too: reaching the disk is `sealedKeysProbe`, deciding is `sealedKeyFindings`.
5
+
6
+ import {
7
+ ERROR_DOCS_URL,
8
+ findMasterKey,
9
+ readSecretsFile,
10
+ SECRETS_RETIRED_KEYS_ENV,
11
+ sealKeyIds,
12
+ secretsFileExists,
13
+ } from '@ultimat3/core';
14
+ import { registeredEntities, sealedFields } from '@ultimat3/entity';
15
+ import { loadApp } from './app-load';
16
+ import type { Finding } from './output';
17
+ import { quoteArg } from './shell-quote';
18
+
19
+ export interface SealedColumnFact {
20
+ readonly entity: string;
21
+ /** Property key, as the entity declares it. */
22
+ readonly column: string;
23
+ readonly lookup: boolean;
24
+ }
25
+
26
+ export interface SealedKeysFact {
27
+ /**
28
+ * Every sealed column the app declares, or `undefined` when the app would not load — a short
29
+ * registry reads as "no sealed columns", which is the false green `appEntities` exists to refuse.
30
+ */
31
+ readonly columns: readonly SealedColumnFact[] | undefined;
32
+ /** The key ids in force, or `undefined` when no usable master key was found. */
33
+ readonly keys: { readonly current: string; readonly retired: readonly string[] } | undefined;
34
+ }
35
+
36
+ const named = (columns: readonly SealedColumnFact[]): string =>
37
+ columns.map((one) => `${one.entity}.${one.column}`).join(', ');
38
+
39
+ /**
40
+ * The rule, pure. Two conditions, and an app with no sealed column is judged by neither.
41
+ *
42
+ * No key: every write to a sealed column is `X_SEAL_KEY_MISSING` — core's own code and its own
43
+ * fix, reported here before the first request finds out.
44
+ *
45
+ * A retired key still declared: a rotation whose re-seal has not finished. That is a live-system
46
+ * state and not a fault, but it has a cost that ends only when someone acts — a `lookup` column
47
+ * matches through `in (…)` over every key and its `.unique()` holds per key — so it is a finding
48
+ * until the retired key is dropped.
49
+ */
50
+ export function sealedKeyFindings(fact: SealedKeysFact): readonly Finding[] {
51
+ const columns = fact.columns;
52
+ if (columns === undefined || columns.length === 0) return [];
53
+ if (fact.keys === undefined) {
54
+ return [
55
+ {
56
+ code: 'X_SEAL_KEY_MISSING',
57
+ cause: `${named(columns)} ${columns.length === 1 ? 'is' : 'are'} sealed and this environment has no usable master key, so every write to ${columns.length === 1 ? 'it' : 'them'} is refused`,
58
+ fix: 'x secrets init # or, where the key already exists: export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)"',
59
+ docs: ERROR_DOCS_URL,
60
+ },
61
+ ];
62
+ }
63
+ const [oldest] = fact.keys.retired;
64
+ if (oldest === undefined) return [];
65
+ const lookups = columns.filter((one) => one.lookup);
66
+ const weakened =
67
+ lookups.length === 0
68
+ ? ''
69
+ : ` — ${named(lookups)} ${lookups.length === 1 ? 'is a lookup column' : 'are lookup columns'}, so until then equality reads through in (…) over every key and .unique() holds per key`;
70
+ return [
71
+ {
72
+ code: 'X_SEAL_RESEAL_PENDING',
73
+ cause: `${named(columns)} may still hold values sealed under retired master key(s) ${fact.keys.retired.join(', ')}: a rotation whose re-seal has not finished${weakened}`,
74
+ // The id is 16 hex characters computed here from a key, never text read from a file.
75
+ fix: `x secrets rotate --drop ${quoteArg(oldest)} --json # AFTER a backfill() has rewritten every row — update(id, { column: row.column }) seals under the current key`,
76
+ docs: ERROR_DOCS_URL,
77
+ },
78
+ ];
79
+ }
80
+
81
+ /**
82
+ * The retired ring as a booted process would see it: the real environment first, the committed
83
+ * file second — `installSecrets()`'s own order, without installing anything into this process.
84
+ */
85
+ async function retiredRing(
86
+ root: string,
87
+ env: Readonly<Record<string, string | undefined>>,
88
+ ): Promise<string | undefined> {
89
+ const declared = env[SECRETS_RETIRED_KEYS_ENV];
90
+ if (declared !== undefined && declared !== '') return declared;
91
+ const key = findMasterKey(root, { ...env });
92
+ if (key === undefined || !secretsFileExists(root)) return undefined;
93
+ return (await readSecretsFile(root, key))[SECRETS_RETIRED_KEYS_ENV];
94
+ }
95
+
96
+ /** The app's sealed columns, or `undefined` when it would not load. */
97
+ export type DeclaredSealedColumns = (
98
+ root: string,
99
+ ) => Promise<readonly SealedColumnFact[] | undefined>;
100
+
101
+ /** `appEntities`' rule (`schema-drift.ts`): a registry short one module is not "none declared". */
102
+ const appSealedColumns: DeclaredSealedColumns = async (root) => {
103
+ const app = await loadApp(root);
104
+ if (app.findings.length > 0) return undefined;
105
+ return registeredEntities().flatMap((entry) =>
106
+ // `core` is absent on a hand-registered entry, which declares no columns to seal.
107
+ (entry.core === undefined ? [] : sealedFields(entry.core)).map((field) => ({
108
+ entity: entry.name,
109
+ column: field.property,
110
+ lookup: field.lookup,
111
+ })),
112
+ );
113
+ };
114
+
115
+ export async function sealedKeysProbe(
116
+ root: string,
117
+ env: Readonly<Record<string, string | undefined>> = process.env,
118
+ declared: DeclaredSealedColumns = appSealedColumns,
119
+ ): Promise<SealedKeysFact> {
120
+ const columns = await declared(root);
121
+ // No sealed column means no question about keys, and no reason to open the secrets file.
122
+ if (columns === undefined || columns.length === 0) return { columns, keys: undefined };
123
+ try {
124
+ const ring = await retiredRing(root, env);
125
+ const source = { ...env, ...(ring === undefined ? {} : { [SECRETS_RETIRED_KEYS_ENV]: ring }) };
126
+ return { columns, keys: await sealKeyIds({ root, env: source }) };
127
+ } catch {
128
+ // Whatever refused — no key, a malformed one, a secrets file this key does not open — the
129
+ // answer for a sealed column is the same: there is no key ring to seal under.
130
+ return { columns, keys: undefined };
131
+ }
132
+ }
@@ -0,0 +1,82 @@
1
+ // Whether importing an app module would bring a DOCUMENT module into this process: a component
2
+ // (`.tsx`) or a stylesheet. A role that renders nothing asks this before each import
3
+ // (`role-load.ts`), so a worker never compiles the app's Sass or evaluates its pages to learn that
4
+ // they register no job.
5
+
6
+ import { resolvedImports } from './module-imports';
7
+ import { hasPathSegment } from './path-segments';
8
+
9
+ /** A component or a stylesheet: what only a process that renders documents evaluates. */
10
+ const DOCUMENT = /\.(?:[jt]sx|s[ac]ss|css)$/;
11
+
12
+ export const isDocumentModule = (path: string): boolean => DOCUMENT.test(path);
13
+
14
+ /** An installed framework package, under either linker: `…/node_modules/@ultimat3/<pkg>/…`. */
15
+ const FRAMEWORK_SCOPE = '/node_modules/@ultimat3/';
16
+
17
+ export interface DocumentGraph {
18
+ /**
19
+ * True when importing `absolute` would evaluate a document module this process has not already
20
+ * loaded. Errs toward `false`: a specifier that will not resolve and a file that will not parse
21
+ * contribute no edge, so the caller imports the module and the import itself reports the fault.
22
+ */
23
+ reachesDocument(absolute: string): Promise<boolean>;
24
+ }
25
+
26
+ /**
27
+ * `loaded` answers whether a module is already in this process — a module the API index imported
28
+ * costs nothing more, whatever it reaches, so the walk stops there. Injected, so a test decides it.
29
+ */
30
+ export function createDocumentGraph(loaded: (absolute: string) => boolean): DocumentGraph {
31
+ const edges = new Map<string, readonly string[]>();
32
+ const verdicts = new Map<string, boolean>();
33
+
34
+ /**
35
+ * The files importing `path` evaluates, when the walk follows them: the app's own modules and
36
+ * the framework's — `@ultimat3/ui` is a barrel of components, and an app module importing it
37
+ * brings every one of them and their Sass. A third-party package is not walked: it ships no
38
+ * component this framework's loader compiles. A lazy import's target is no edge at all.
39
+ */
40
+ async function importsOf(path: string): Promise<readonly string[]> {
41
+ const known = edges.get(path);
42
+ if (known !== undefined) return known;
43
+ // Unreadable: no edge, and the caller's own import is what reports it.
44
+ const source = await Bun.file(path)
45
+ .text()
46
+ .catch(() => '');
47
+ const found = resolvedImports(path, source, 'evaluated').filter(
48
+ (target) => !hasPathSegment(target, 'node_modules') || target.includes(FRAMEWORK_SCOPE),
49
+ );
50
+ edges.set(path, found);
51
+ return found;
52
+ }
53
+
54
+ return {
55
+ async reachesDocument(absolute: string): Promise<boolean> {
56
+ const known = verdicts.get(absolute);
57
+ if (known !== undefined) return known;
58
+ // The whole reachable set, never a per-node recursion: inside an import cycle a node's own
59
+ // answer depends on one still being computed, and a `false` memoised there is wrong.
60
+ const seen = new Set<string>();
61
+ const stack = [absolute];
62
+ let reaches = false;
63
+ while (stack.length > 0 && !reaches) {
64
+ const path = stack.pop();
65
+ if (path === undefined || seen.has(path)) continue;
66
+ seen.add(path);
67
+ if (verdicts.get(path) === true || (isDocumentModule(path) && !loaded(path))) {
68
+ reaches = true;
69
+ break;
70
+ }
71
+ // Already here, or already cleared: nothing below it is new to this process.
72
+ if (isDocumentModule(path) || loaded(path) || verdicts.get(path) === false) continue;
73
+ stack.push(...(await importsOf(path)));
74
+ }
75
+ // A clean walk clears everything it visited — each one's reachable set is inside this one's.
76
+ // A walk that found a document proves it for the start alone.
77
+ if (reaches) verdicts.set(absolute, true);
78
+ else for (const path of seen) verdicts.set(path, false);
79
+ return reaches;
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,64 @@
1
+ // What a document renderer is handed by the boot that knows it: the island resolver and every
2
+ // document-level head the route itself cannot decide. Split out of `runtime-render.ts` at its
3
+ // 500-line ceiling; one declaration for `x dev`, the container and the static export.
4
+
5
+ import type { ClientSyncHead } from '@ultimat3/render';
6
+ import type { NavigationDocumentHead } from './page-navigation';
7
+
8
+ /**
9
+ * Specifier → built chunk URL, bound to the route file the specifier is written relative to.
10
+ * Supplied by whoever built the islands (`x dev`, the container, the static build); absent means
11
+ * no island was built, and a page that renders one then fails by name rather than emitting a
12
+ * `data-x-entry` nothing can import.
13
+ */
14
+ export type IslandResolver = (routeFile: string) => (src: string) => string;
15
+
16
+ export interface DocumentOptions {
17
+ readonly resolveIsland?: IslandResolver;
18
+ /**
19
+ * `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
20
+ * from `pwa-artifacts.ts`, or absent when the app is not installable.
21
+ *
22
+ * A document-level string rather than something a route's `meta()` returns: it is the same three
23
+ * elements on every page of the app, an installable app is one whose EVERY page carries them
24
+ * (a browser offers the install on whichever page the visitor landed on), and `headFromMeta`
25
+ * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
26
+ * boot knows it, the renderer cannot ask.
27
+ */
28
+ readonly pwaHead?: string | ((locale: string) => string);
29
+ /**
30
+ * The no-flash theme `<script>` from `theme-boot.ts`, or absent for a caller that renders no
31
+ * documents a browser paints. Document-level for `pwaHead`'s reason — the same tag on every page,
32
+ * decided by `app.config.ts`, which the boot read and the renderer cannot.
33
+ */
34
+ readonly themeHead?: string;
35
+ /**
36
+ * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
37
+ * document this process serves. Principal-free, so a shareable document carries it too; absent
38
+ * for a caller that serves no socket at all (the static export).
39
+ */
40
+ readonly sync?: ClientSyncHead;
41
+ /**
42
+ * The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
43
+ * as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
44
+ * with no scope carries none.
45
+ */
46
+ readonly persisted?: () => readonly string[];
47
+ /**
48
+ * The public origin canonical, `og:url` and hreflang are absolute against — `publicOrigin()`
49
+ * from `site-config.ts`. Absent, the request's own origin: a relative canonical is one a crawler
50
+ * resolves against whatever host it happened to fetch from, a CDN's or a preview's.
51
+ */
52
+ readonly origin?: string;
53
+ /**
54
+ * The client router — `pageNavigation(…).head`. A document of a surface listed in
55
+ * `navigation.client` names it; every other document carries none of it.
56
+ */
57
+ readonly navigation?: NavigationDocumentHead;
58
+ /**
59
+ * `<script type="speculationrules">` — `pageSpeculation(…).head` — on every document that names
60
+ * NO client router: the browser prefetches the next page instead. A document with the router
61
+ * carries none, because the router prefetches by its own rules.
62
+ */
63
+ readonly speculationHead?: string;
64
+ }
@@ -141,6 +141,11 @@ export const CLI_OWNED_ERROR_CODES = [
141
141
  'X_GUARD_INVALID',
142
142
  'X_GUARD_FAILED',
143
143
  'X_GUARD_FINDING_INVALID',
144
+ // The browser-transport rule, on `boundaries` in every app (`app-transport.ts`). Both codes
145
+ // shipped as this repository's own script findings; the rule moved into the gate, so the CLI
146
+ // emits them and therefore owns them — the code strings and their meaning are unchanged.
147
+ 'X_BROWSER_TRANSPORT_BYPASS',
148
+ 'X_BROWSER_SERVER_BARREL',
144
149
  // The CLI's own declarations, held to each other. A flag the parser accepts and no code reads
145
150
  // is a promise in `x help` with nothing behind it — `x deploy --critical` said "forces clients
146
151
  // to reload" and reached no reader outside the plan JSON it was written into.
@@ -205,6 +210,16 @@ export const CLI_OWNED_ERROR_CODES = [
205
210
  'X_SITEMAP_EXTRA_INVALID',
206
211
  'X_DEV_RESTART_REQUIRED',
207
212
  'X_DEV_ROOT_GONE',
213
+ 'X_SEAL_RESEAL_PENDING',
214
+ 'X_VERIFY_STEP_TIMEOUT',
215
+ 'X_COVERAGE_BELOW_FLOOR',
216
+ 'X_COVERAGE_FLOOR_UNSTATED',
217
+ 'X_COVERAGE_FLOOR_STALE',
218
+ 'X_DB_HANDLE_UNREGISTERED',
219
+ 'X_ROLE_LOAD_INCOMPLETE',
220
+ 'X_ADMIN_RESOURCE_UNWIRED',
221
+ 'X_IMAGE_NOT_PREBUILT',
222
+ 'X_ADMIN_UNSCANNED',
208
223
  ] as const;
209
224
 
210
225
  /**
@@ -314,6 +329,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
314
329
  X_GUARD_INVALID: 'a file in guards/ exports no usable guard',
315
330
  X_GUARD_FAILED: 'an app guard threw instead of returning findings',
316
331
  X_GUARD_FINDING_INVALID: "an app guard's finding breaks the error contract",
332
+ X_BROWSER_TRANSPORT_BYPASS:
333
+ 'browser-reachable code opens its own connection instead of using the one seam',
334
+ X_BROWSER_SERVER_BARREL:
335
+ "browser-reachable code imports a package's server barrel where the package publishes a browser entry",
317
336
  X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
318
337
  X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
319
338
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
@@ -360,6 +379,19 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
360
379
  X_DEV_RESTART_REQUIRED:
361
380
  'a save reached a module that defines a primitive; only a new process serves it',
362
381
  X_DEV_ROOT_GONE: 'the app root x dev serves was deleted or moved',
382
+ X_SEAL_RESEAL_PENDING: 'sealed columns still hold values under a retired master key',
383
+ X_VERIFY_STEP_TIMEOUT: 'a gate step ran past its deadline and was stopped',
384
+ X_COVERAGE_BELOW_FLOOR: 'the unit suite covers less of the app than x.verify.json states',
385
+ X_COVERAGE_FLOOR_UNSTATED:
386
+ 'x.verify.json states no coverage floor, or one under 95 with no reason',
387
+ X_COVERAGE_FLOOR_STALE: 'the app now covers more than the floor in x.verify.json states',
388
+ X_DB_HANDLE_UNREGISTERED: "an entity x g wrote is not in the app's typed database handle",
389
+ X_ROLE_LOAD_INCOMPLETE: 'a worker or scheduler did not register a job or task the manifest names',
390
+ X_ADMIN_RESOURCE_UNWIRED: "an admin override x g wrote is not in the app's defineAdmin() call",
391
+ X_IMAGE_NOT_PREBUILT:
392
+ 'a container role built at boot what the image build should have: island chunks or stylesheets',
393
+ X_ADMIN_UNSCANNED:
394
+ 'a defineAdmin() declaration sits outside the app scan, so the admin is never mounted',
363
395
  };
364
396
 
365
397
  // One unconditional call, so a second package claiming one of the CLI's codes throws