@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
@@ -7,6 +7,7 @@
7
7
  // the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
8
8
 
9
9
  import { SQL_AUDIT_TABLE, SQL_IDEMPOTENCY_TABLE } from '@ultimat3/action';
10
+ import { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from '@ultimat3/admin/schema';
10
11
  import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
11
12
  import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
12
13
  import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
@@ -49,6 +50,9 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
49
50
  'x_scheduler_leader',
50
51
  'x_job_leases',
51
52
  'x_job_events',
53
+ 'x_job_pauses',
54
+ 'x_job_workers',
55
+ 'x_job_counters',
52
56
  ]),
53
57
  ddl: Object.freeze([SQL_JOBS_TABLE]),
54
58
  }),
@@ -109,6 +113,17 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
109
113
  tables: Object.freeze(['x_notify_inbox']),
110
114
  ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
111
115
  }),
116
+ /**
117
+ * The admin's durable audit trail — what `postgresAuditLog()` writes and every detail page's
118
+ * history card reads. Without this row an app that chose the durable log met `42P01` on the
119
+ * first audited request. Imported through `@ultimat3/admin/schema`, a leaf module: the DDL is
120
+ * on every role's boot path, and not one admin screen is (`serve-graph.test.ts`).
121
+ */
122
+ Object.freeze({
123
+ pkg: '@ultimat3/admin',
124
+ tables: Object.freeze([ADMIN_AUDIT_TABLE]),
125
+ ddl: Object.freeze([SQL_ADMIN_AUDIT_TABLE]),
126
+ }),
112
127
  ]);
113
128
 
114
129
  /** Every relation this boot creates, flattened. */
@@ -2,10 +2,11 @@
2
2
  // `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
3
3
  // scaffold fixture and `x new` — without a command line, an app root or a filesystem.
4
4
 
5
- import { CliNotImplementedError } from './errors';
5
+ import { BadFlagError, CliNotImplementedError } from './errors';
6
6
  import type { Generator } from './generate-kinds';
7
- import { assertSurfaceSupported, GENERATORS } from './generate-kinds';
7
+ import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
8
  import { dedupe } from './generate-write';
9
+ import { quoteArg } from './shell-quote';
9
10
  import type { GeneratedFile, Surface } from './templates';
10
11
  import {
11
12
  actionFiles,
@@ -16,12 +17,15 @@ import {
16
17
  islandFiles,
17
18
  jobFiles,
18
19
  kebab,
20
+ names,
19
21
  policyFiles,
20
22
  queryFiles,
21
23
  resourceFiles,
22
24
  routeFiles,
25
+ shippedGuardFiles,
23
26
  taskFiles,
24
27
  } from './templates';
28
+ import { adminCatalogFiles } from './templates/admin-catalog';
25
29
 
26
30
  export interface GenerateOptions {
27
31
  readonly kind: Generator;
@@ -56,15 +60,27 @@ export interface GenerateOptions {
56
60
  */
57
61
  readonly sliceErrors?: string;
58
62
  /**
59
- * `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
63
+ * `action`, `mutator`, `query`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
60
64
  * the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
61
65
  * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
62
- * `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
66
+ * `repo.byId`/`repo.list` calls into a feature whose entity names no tenant column. Read at
63
67
  * `sliceDir(surface, feature)/entity.ts`.
64
68
  */
65
69
  readonly sliceEntity?: string;
66
- /** `job` and `task`: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
70
+ /** The same kinds: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
67
71
  readonly sliceRepo?: string;
72
+ /**
73
+ * The app's own db package, `@<app>/db` — what every generated `repo.ts` imports the typed handle
74
+ * from. Supplied by `run` (`resolveDbModule`) for `catalogModule`'s reason: the name lives in a
75
+ * manifest on disk. Every kind that can write a slice's `repo.ts` reads it, so it rides on the
76
+ * shared target rather than on one case.
77
+ */
78
+ readonly dbModule?: string;
79
+ /**
80
+ * `resource` only: the app has the frame `x new` writes (`apps/web/shared/shell.tsx`), so the
81
+ * resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason.
82
+ */
83
+ readonly shell?: boolean;
68
84
  }
69
85
 
70
86
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -76,6 +92,26 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
76
92
  export const sliceDir = (surface: Surface, feature: string): string =>
77
93
  `${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
78
94
 
95
+ /**
96
+ * `x g resource <name> --feature <other>`. A resource IS its feature: every file it composes — the
97
+ * actions, the live query, the job — names the entity after the slice (`../entity`'s `Run`,
98
+ * `canRunWrite`), so a slice under a second name is a set of imports that resolve to nothing. The
99
+ * flag was read and then overwritten with the name, which wrote the slice somewhere the author did
100
+ * not ask for and said nothing. Refused instead; a value that only restates the name is no second
101
+ * name.
102
+ */
103
+ function refuseResourceFeature(options: GenerateOptions, surfaceDir: string): void {
104
+ if (options.kind !== 'resource' || options.feature === undefined) return;
105
+ const named = kebab(options.name);
106
+ if (kebab(options.feature) === named) return;
107
+ throw new BadFlagError({
108
+ flag: 'feature',
109
+ command: 'g resource',
110
+ reason: `a resource names its own slice — "${options.name}" is written to ${surfaceDir}/${named}/ and its page to ${surfaceDir}/${names(options.name).pluralKebab}/ — so --feature ${options.feature} would be a second name for one slice`,
111
+ fix: `x g resource ${quoteArg(options.name)}`,
112
+ });
113
+ }
114
+
79
115
  /**
80
116
  * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
81
117
  * on them, and nothing has to run a filesystem to review what a generator produces.
@@ -83,11 +119,17 @@ export const sliceDir = (surface: Surface, feature: string): string =>
83
119
  export function generate(options: GenerateOptions): readonly GeneratedFile[] {
84
120
  const surface: Surface = options.surface ?? 'app';
85
121
  assertSurfaceSupported(options.kind, surface, options.name);
122
+ assertLiveSupported(options.kind, options.live === true, options.name);
86
123
  const surfaceDir = DEFAULT_SURFACE_DIR[surface];
124
+ refuseResourceFeature(options, surfaceDir);
87
125
  // Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
88
126
  // `app/blog-post/`, so one feature grew two slice directories depending on the generator.
89
127
  const feature = kebab(options.feature ?? options.name);
90
- const target = { surfaceDir, feature };
128
+ const target = {
129
+ surfaceDir,
130
+ feature,
131
+ ...(options.dbModule === undefined ? {} : { dbModule: options.dbModule }),
132
+ };
91
133
  switch (options.kind) {
92
134
  case 'resource':
93
135
  return dedupe(
@@ -96,6 +138,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
96
138
  admin: options.admin === true,
97
139
  ...(options.locales === undefined ? {} : { locales: options.locales }),
98
140
  ...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
141
+ ...(options.shell === undefined ? {} : { shell: options.shell }),
99
142
  }),
100
143
  );
101
144
  case 'action':
@@ -104,6 +147,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
104
147
  ...target,
105
148
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
106
149
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
150
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
107
151
  }),
108
152
  );
109
153
  case 'mutator':
@@ -113,16 +157,35 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
113
157
  mutator: true,
114
158
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
115
159
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
160
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
116
161
  }),
117
162
  );
118
163
  case 'backfill':
119
- return dedupe(backfillFiles(options.name, target));
164
+ return dedupe(
165
+ backfillFiles(options.name, {
166
+ ...target,
167
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
168
+ }),
169
+ );
120
170
  case 'entity':
121
- return dedupe(entityFiles(options.name, target));
171
+ // With the labels the admin reads for it: the entity is an admin screen once it is in the
172
+ // handle, whether or not a resource was generated around it.
173
+ return dedupe([
174
+ ...entityFiles(options.name, target),
175
+ ...adminCatalogFiles(options.name, target, options.locales),
176
+ ]);
122
177
  case 'policy':
123
178
  return dedupe(policyFiles(options.name, target));
124
179
  case 'query':
125
- return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
180
+ return dedupe(
181
+ queryFiles(options.name, {
182
+ ...target,
183
+ live: options.live === true,
184
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
185
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
186
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
187
+ }),
188
+ );
126
189
  case 'job':
127
190
  return dedupe(
128
191
  jobFiles(options.name, {
@@ -143,8 +206,11 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
143
206
  return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
144
207
  // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
145
208
  // guard that lived anywhere else would need an app-side registration to be found.
209
+ // A name the framework ships a guard under gets THAT guard — how an app adopts one it does
210
+ // not have (`x doctor` names them). Any other name is the app's own convention, from the blank
211
+ // template.
146
212
  case 'guard':
147
- return dedupe(guardFiles(options.name));
213
+ return dedupe(shippedGuardFiles(kebab(options.name)) ?? guardFiles(options.name));
148
214
  case 'admin:page':
149
215
  // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
150
216
  return dedupe(
@@ -0,0 +1,76 @@
1
+ // What `x g` could NOT grant, said out loud. `generate-grants.ts` performs the role-map edit
2
+ // where the scaffold keeps one; an app whose roles live elsewhere got no grant and no finding, and
3
+ // every generated endpoint answered 403. Apart from that module because this one only REPORTS:
4
+ // it re-asks the same pure edits of the file as it now stands and names what they could not place.
5
+
6
+ import type { RoleGrant } from './generate-grants';
7
+ import {
8
+ adminGrantsFor,
9
+ grantsForWritten,
10
+ insertGrants,
11
+ insertPermissions,
12
+ writtenTables,
13
+ } from './generate-grants';
14
+ import { containedPath } from './generate-write';
15
+ import type { Finding } from './output';
16
+ import { ROLES_FILE } from './permission-grants';
17
+
18
+ /** `member: 'run:read'; admin: 'run:write', 'runs:read'` — each role, and what its `grants` owes. */
19
+ const linesFor = (grants: readonly RoleGrant[]): string => {
20
+ const byRole = new Map<string, string[]>();
21
+ for (const grant of grants) {
22
+ byRole.set(grant.role, [...(byRole.get(grant.role) ?? []), `'${grant.permission}'`]);
23
+ }
24
+ return [...byRole].map(([role, permissions]) => `${role}: ${permissions.join(', ')}`).join('; ');
25
+ };
26
+
27
+ /** The grants the role map as it stands still lacks: no such role, no `grants`, or undeclared. */
28
+ function stillMissing(source: string, grants: readonly RoleGrant[], admin: readonly RoleGrant[]) {
29
+ const undeclared = insertPermissions(
30
+ source,
31
+ admin.map((grant) => grant.permission),
32
+ ).skipped;
33
+ const unplaced = insertGrants(source, grants).skipped;
34
+ return grants.filter(
35
+ (grant) => unplaced.includes(grant) || undeclared.includes(grant.permission),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * One finding for everything this run declared and no role holds, or none. Asked AFTER
41
+ * `grantGeneratedPermissions`, of the file it left: a grant it placed is not reported, and one it
42
+ * could not place — no role map at the scaffold's path, a map with no such role, a map with no
43
+ * `definePermissions([…])` to declare an admin permission in — is, with the role each belongs to.
44
+ */
45
+ export async function ungrantedByGenerator(
46
+ root: string,
47
+ written: readonly string[],
48
+ ): Promise<readonly Finding[]> {
49
+ const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
50
+ const grants = [...grantsForWritten(written), ...admin];
51
+ if (grants.length === 0) return [];
52
+ const file = Bun.file(containedPath(root, ROLES_FILE));
53
+ // The file whose declarations nobody granted: where a reader starts when the map is not ours.
54
+ const declaredIn =
55
+ written.find((path) => path.endsWith('/policy.ts')) ?? written[0] ?? ROLES_FILE;
56
+ if (!(await file.exists())) {
57
+ return [
58
+ {
59
+ code: 'X_PERMISSION_UNGRANTED',
60
+ cause: `${ROLES_FILE} is not in this app, so nothing granted what this run declared — every generated endpoint answers 403 until a role holds: ${linesFor(grants)}`,
61
+ fix: `x policy list --json # names the module that calls defineRoles(); add to its roles' grants — ${linesFor(grants)} — then x verify --only policy`,
62
+ at: declaredIn,
63
+ },
64
+ ];
65
+ }
66
+ const missing = stillMissing(await file.text(), grants, admin);
67
+ if (missing.length === 0) return [];
68
+ return [
69
+ {
70
+ code: 'X_PERMISSION_UNGRANTED',
71
+ cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be added to, so nothing granted: ${linesFor(missing)}`,
72
+ fix: `edit ${ROLES_FILE} — add to each role's grants (and declare any the map does not know in definePermissions([...])) — ${linesFor(missing)} — then x verify --only policy`,
73
+ at: ROLES_FILE,
74
+ },
75
+ ];
76
+ }
@@ -2,6 +2,10 @@
2
2
  // `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
3
3
  // answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
4
4
  // `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
5
+ //
6
+ // And a generated ENTITY is a screen at `/admin/<table>`, behind `<table>:read|write|delete` —
7
+ // three permissions `defineAdmin()` derives from the table's own name. They are declared in the
8
+ // role map and granted to `admin`, or the role that runs the admin is refused its newest screen.
5
9
 
6
10
  import { containedPath } from './generate-write';
7
11
  import { ROLES_FILE } from './permission-grants';
@@ -33,6 +37,23 @@ export function grantsForWritten(written: readonly string[]): readonly RoleGrant
33
37
  });
34
38
  }
35
39
 
40
+ /** A generated entity file: `<surface>/<feature>/entity.ts`. */
41
+ const ENTITY_PATH = /^apps\/[^/]+\/[^/]+\/[a-z0-9-]+\/entity\.ts$/;
42
+ /** `entity('widgets', {` — the name the admin's permissions are spelled with. */
43
+ const ENTITY_NAME = /\bentity\(\s*'([^']+)'/;
44
+
45
+ /**
46
+ * What the admin asks for one table, granted to the role that runs it. The verbs are
47
+ * `@ultimat3/admin`'s `entityPermissionFor` — `read` to list, open and search, `write` to create
48
+ * and edit, `delete` to delete — and the resource is the ENTITY's name, never the feature's.
49
+ */
50
+ export function adminGrantsFor(table: string): readonly RoleGrant[] {
51
+ return ['read', 'write', 'delete'].map((verb) => ({
52
+ role: 'admin',
53
+ permission: `${table}:${verb}`,
54
+ }));
55
+ }
56
+
36
57
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
58
 
38
59
  /**
@@ -67,16 +88,69 @@ export function insertGrants(
67
88
  return { source: next, skipped };
68
89
  }
69
90
 
70
- /** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
91
+ const DECLARATION = 'definePermissions([';
92
+
93
+ /**
94
+ * `source` with each permission added to its first `definePermissions([…])`, re-wrapped the way
95
+ * Biome prints it. Declared BEFORE it is granted: `can()` refuses a name no call registered, and
96
+ * `defineRoles()` does not — a grant nothing declares is a 500 on the first request that asks.
97
+ * A role map with no such call is left alone and every permission returned in `skipped`.
98
+ */
99
+ export function insertPermissions(
100
+ source: string,
101
+ permissions: readonly string[],
102
+ ): { readonly source: string; readonly skipped: readonly string[] } {
103
+ const open = source.indexOf(DECLARATION);
104
+ const close = open === -1 ? -1 : source.indexOf('])', open);
105
+ if (open === -1 || close === -1) return { source, skipped: [...permissions] };
106
+ const current = [...source.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
107
+ const added = permissions.filter((permission) => !current.includes(permission));
108
+ if (added.length === 0) return { source, skipped: [] };
109
+ const lineStart = source.lastIndexOf('\n', open) + 1;
110
+ const head = source.slice(lineStart, open);
111
+ const tail = source.slice(close + 2, source.indexOf('\n', close));
112
+ const entries = [...current, ...added].map((permission) => `'${permission}'`);
113
+ const rewritten = wrapList('', `${head}${DECLARATION}`, entries, `])${tail}`);
114
+ return {
115
+ source: `${source.slice(0, lineStart)}${rewritten}${source.slice(source.indexOf('\n', close))}`,
116
+ skipped: [],
117
+ };
118
+ }
119
+
120
+ /** The entity names the written `entity.ts` files declare — read off each file, never guessed. */
121
+ export async function writtenTables(
122
+ root: string,
123
+ written: readonly string[],
124
+ ): Promise<readonly string[]> {
125
+ const tables: string[] = [];
126
+ for (const path of written.filter((candidate) => ENTITY_PATH.test(candidate))) {
127
+ const table = ENTITY_NAME.exec(await Bun.file(containedPath(root, path)).text())?.[1];
128
+ if (table !== undefined) tables.push(table);
129
+ }
130
+ return tables;
131
+ }
132
+
133
+ /**
134
+ * Performs both edits on the app's role map: a written policy's grants, and — for each written
135
+ * entity — the admin's three permissions for its table, declared and granted. Answers the paths
136
+ * it rewrote.
137
+ */
71
138
  export async function grantGeneratedPermissions(
72
139
  root: string,
73
140
  written: readonly string[],
74
141
  ): Promise<readonly string[]> {
75
- const grants = grantsForWritten(written);
142
+ const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
143
+ const grants = [...grantsForWritten(written), ...admin];
76
144
  const file = containedPath(root, ROLES_FILE);
77
145
  if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
78
146
  const before = await Bun.file(file).text();
79
- const { source } = insertGrants(before, grants);
147
+ const declared = insertPermissions(
148
+ before,
149
+ admin.map((grant) => grant.permission),
150
+ );
151
+ // Granted only where it could be declared: a grant with no declaration is the 500 above.
152
+ const grantable = grants.filter((grant) => !declared.skipped.includes(grant.permission));
153
+ const { source } = insertGrants(declared.source, grantable);
80
154
  if (source === before) return [];
81
155
  await Bun.write(file, source);
82
156
  return [ROLES_FILE];
@@ -9,6 +9,7 @@ import {
9
9
  MissingSubcommandError,
10
10
  UnknownCommandError,
11
11
  } from './errors';
12
+ import { quoteArg } from './shell-quote';
12
13
  import type { Surface } from './templates';
13
14
  import { camel, kebab } from './templates/naming';
14
15
 
@@ -49,6 +50,24 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
49
50
  });
50
51
  }
51
52
 
53
+ /**
54
+ * `--live` is the query generator's. A resource slice already ships a live list query, so the flag
55
+ * on any other generator changed nothing — and a flag accepted and ignored is a second spelling of
56
+ * the same command that an agent then believes did something.
57
+ */
58
+ export function assertLiveSupported(kind: Generator, live: boolean, name: string): void {
59
+ if (!live || kind === 'query') return;
60
+ throw new BadFlagError({
61
+ flag: 'live',
62
+ command: `g ${kind}`,
63
+ reason:
64
+ kind === 'resource'
65
+ ? "--live is the query generator's: a resource slice already ships a live list query"
66
+ : "--live is the query generator's: it declares a query live",
67
+ fix: `x g query ${quoteArg(`${name}-feed`)} --feature ${quoteArg(name)} --live`,
68
+ });
69
+ }
70
+
52
71
  /**
53
72
  * The generator a spelling reaches, or a refusal that leads with the one it is nearest.
54
73
  *
@@ -9,7 +9,7 @@ import { existsSync } from 'node:fs';
9
9
  import { resolve, sep } from 'node:path';
10
10
  import { ERROR_DOCS_URL } from '@ultimat3/core';
11
11
  import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
12
- import { mergeJsonDeep } from './json-merge';
12
+ import { mergeJsonDeep, mergeJsonInPlace, sortJsonDeep } from './json-merge';
13
13
  import type { Finding } from './output';
14
14
  import type { GeneratedFile } from './templates';
15
15
 
@@ -28,33 +28,18 @@ function parseJsonObject(text: string): Record<string, unknown> | undefined {
28
28
  }
29
29
 
30
30
  /**
31
- * Every key of every level, sorted. A catalog is authored NESTED (`{ nav: { home: … } }`) and this
32
- * sorted the top level only, so the keys INSIDE `app` kept the order their generators ran in —
33
- * `x g route zebra` then `x g route alpha` wrote different bytes from the same two runs in the
34
- * other order, which is the reordering diff the sort exists to prevent. Arrays keep their order:
35
- * a list's order is its content.
31
+ * A generator's OWN catalog bytes: every key of every level sorted, 2-space indent, trailing
32
+ * newline. A catalog is authored NESTED (`{ nav: { home: … } }`) and sorting the top level only
33
+ * left the keys inside `app` in the order their generators ran — `x g route zebra` then
34
+ * `x g route alpha` wrote different bytes from the same two runs in the other order. The sort is
35
+ * `json-merge.ts`'s `sortJsonDeep`, by code unit: `localeCompare` is `Intl`-backed, so the bytes
36
+ * would move with the machine's locale and its ICU version.
36
37
  *
37
- * CODE UNIT, never `localeCompare`: it is `Intl`-backed, so the bytes a catalog is written with
38
- * moved with the machine's locale and its ICU version — `x g route` on a `tr-TR` box and the same
39
- * command in CI produced two orderings of one file, which is exactly the reordering diff this sort
40
- * exists to prevent, one layer down. `@ultimat3/render` states the same rule as `byCodeUnit`; the
41
- * comparison is inlined rather than imported because that one is package-internal and this is the
42
- * whole of it.
38
+ * For a fresh file and for `dedupe`, where every contributor is a generator. A file already on
39
+ * disk is merged in ITS order (`mergeJsonInPlace`), never re-sorted.
43
40
  */
44
- function sortDeep(value: unknown): unknown {
45
- if (Array.isArray(value)) return value.map(sortDeep);
46
- if (typeof value !== 'object' || value === null) return value;
47
- return Object.fromEntries(
48
- Object.entries(value)
49
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
50
- .map(([key, entry]) => [key, sortDeep(entry)]),
51
- );
52
- }
53
-
54
- /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
55
- * the keys a run actually changed, never a reordering. */
56
41
  function prettyJson(value: Record<string, unknown>): string {
57
- return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
42
+ return `${JSON.stringify(sortJsonDeep(value), null, 2)}\n`;
58
43
  }
59
44
 
60
45
  /**
@@ -156,10 +141,13 @@ async function planJsonMerge(
156
141
  }
157
142
  // An existing key wins because it may hold a human translation; only the new keys are added.
158
143
  // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
159
- const { merged, gained } = mergeJsonDeep(existing, generated);
144
+ const { gained } = mergeJsonDeep(existing, generated);
160
145
  // Every key the generator wants is already there — leave the file untouched and unclaimed.
161
146
  if (!gained) return { kind: 'skip' };
162
- return { kind: 'write', file, absolute, contents: prettyJson(merged) };
147
+ // In the file's OWN order: the diff a run leaves is the keys it added, never a re-sort of a
148
+ // catalog somebody keeps grouped by screen.
149
+ const merged = mergeJsonInPlace(existing, generated);
150
+ return { kind: 'write', file, absolute, contents: `${JSON.stringify(merged, null, 2)}\n` };
163
151
  }
164
152
 
165
153
  /**
@@ -0,0 +1,59 @@
1
+ // The workspace edges `x g` just wrote, declared in the manifest of the workspace each file landed
2
+ // in. `package-shape` refuses an import its owner does not declare (X_WORKSPACE_DEP_UNDECLARED), and
3
+ // a generator that emits one hands the author a red gate for the generator's own output — the edit
4
+ // is mechanical, so the generator makes it. Shipped source only, the checker's own scope.
5
+
6
+ import { containedPath } from './generate-write';
7
+ import { isGenerated, isTest } from './source-files';
8
+ import { withDependency } from './workspace-dep-edit';
9
+ import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph';
10
+
11
+ const SOURCE = /\.tsx?$/;
12
+
13
+ /** Per owning workspace, the sibling workspaces its new files import and it does not declare. */
14
+ async function missingEdges(
15
+ root: string,
16
+ written: readonly string[],
17
+ ): Promise<Map<WorkspaceNode, Map<string, string>>> {
18
+ const missing = new Map<WorkspaceNode, Map<string, string>>();
19
+ const sources = written.filter(
20
+ (path) => SOURCE.test(path) && !isTest(path) && !isGenerated(path),
21
+ );
22
+ if (sources.length === 0) return missing;
23
+ const { nodes } = await scanWorkspaces(root);
24
+ const byName = new Map(nodes.map((node) => [node.name, node]));
25
+ // Deepest directory first, as the checker reads it: a nested workspace's files are its own.
26
+ const owners = [...nodes].sort((left, right) => right.dir.length - left.dir.length);
27
+ for (const path of sources) {
28
+ const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
29
+ if (owner === undefined) continue;
30
+ const text = await Bun.file(containedPath(root, path)).text();
31
+ for (const name of importedPackages(text, path)) {
32
+ const target = byName.get(name);
33
+ if (target === undefined || target === owner || owner.dependencies.includes(name)) continue;
34
+ const edges = missing.get(owner) ?? new Map<string, string>();
35
+ edges.set(name, target.version ?? '0.0.0');
36
+ missing.set(owner, edges);
37
+ }
38
+ }
39
+ return missing;
40
+ }
41
+
42
+ /** Writes each owning manifest once, holding every new edge; answers the manifests it rewrote. */
43
+ export async function declareGeneratedImports(
44
+ root: string,
45
+ written: readonly string[],
46
+ ): Promise<readonly string[]> {
47
+ const edited: string[] = [];
48
+ for (const [owner, edges] of await missingEdges(root, written)) {
49
+ const path = `${owner.dir}/package.json`;
50
+ const file = Bun.file(containedPath(root, path));
51
+ const before = await file.text();
52
+ let after = before;
53
+ for (const [name, version] of edges) after = withDependency(after, name, version) ?? after;
54
+ if (after === before) continue;
55
+ await Bun.write(containedPath(root, path), after);
56
+ edited.push(path);
57
+ }
58
+ return edited;
59
+ }
@@ -0,0 +1,104 @@
1
+ // One read of the app per gate run, shared by every guard. Every guard `SHIPPED_GUARD_NAMES` lists
2
+ // (`templates/scaffold-guards.ts`), each walking the tree and re-reading every stylesheet, is one
3
+ // walk per guard for one answer, and two guards compiling the same module is Sass run twice — so
4
+ // the runner hands each `check` these, memoised for the run.
5
+
6
+ // why: `join` builds the host-separator path a glob entry is read at; Bun ships no path API.
7
+ import { join } from 'node:path';
8
+
9
+ export interface GuardFile {
10
+ /** App-root-relative POSIX path, so a finding names the file an author opens. */
11
+ readonly path: string;
12
+ readonly text: string;
13
+ }
14
+
15
+ /** A stylesheet as the build compiles it. */
16
+ export interface GuardStylesheet {
17
+ readonly css: string;
18
+ /** `hero` → its scoped name. Empty for a sheet that is not a `.module.scss`. */
19
+ readonly classes: Readonly<Record<string, string>>;
20
+ }
21
+
22
+ export interface GuardSources {
23
+ /**
24
+ * Every file under the app root matching `glob`, sorted. Dependencies, build output and caches
25
+ * are never the app, so they are never returned. Asked twice, walked once.
26
+ */
27
+ files(glob: string): Promise<readonly GuardFile[]>;
28
+ /**
29
+ * The stylesheet at an app-root-relative path, compiled by the compiler the build uses.
30
+ * `undefined` when the file is absent or does not compile: that refusal is the build's
31
+ * (`X_PRERENDER_FAILED`, with the Sass error), never a guard's to repeat.
32
+ */
33
+ compiled(path: string): Promise<GuardStylesheet | undefined>;
34
+ }
35
+
36
+ /** Path segments that are never the app's own source. */
37
+ const NOT_SOURCE = /(?:^|\/)(?:node_modules|dist|\.x|\.git)\//;
38
+
39
+ /**
40
+ * `seed` is text the gate step ALREADY read — the `boundaries` step reads every surface file for
41
+ * its own import rules before any guard runs, and a guard that walks `apps/**` would read each of
42
+ * them a second time. Seeded, a path is read at most once per step; anything the seed does not
43
+ * hold (a stylesheet, a test, a package) is read here on first ask, as before.
44
+ */
45
+ export function guardSources(
46
+ root: string,
47
+ seed: readonly { readonly path: string; readonly source: string }[] = [],
48
+ ): GuardSources {
49
+ const texts = new Map<string, Promise<string>>();
50
+ for (const file of seed) texts.set(file.path, Promise.resolve(file.source));
51
+ const walks = new Map<string, Promise<readonly GuardFile[]>>();
52
+ const sheets = new Map<string, Promise<GuardStylesheet | undefined>>();
53
+
54
+ const textOf = (path: string): Promise<string> => {
55
+ let text = texts.get(path);
56
+ if (text === undefined) {
57
+ text = Bun.file(join(root, path)).text();
58
+ texts.set(path, text);
59
+ }
60
+ return text;
61
+ };
62
+
63
+ const walk = async (glob: string): Promise<readonly GuardFile[]> => {
64
+ const paths: string[] = [];
65
+ for await (const entry of new Bun.Glob(glob).scan({ cwd: root, absolute: false })) {
66
+ const path = entry.split('\\').join('/');
67
+ if (!NOT_SOURCE.test(path)) paths.push(path);
68
+ }
69
+ paths.sort();
70
+ return Promise.all(paths.map(async (path) => ({ path, text: await textOf(path) })));
71
+ };
72
+
73
+ const compile = async (path: string): Promise<GuardStylesheet | undefined> => {
74
+ try {
75
+ const source = await textOf(path);
76
+ // Imported here, not at the top: Sass loads on the first compile, and a gate run whose
77
+ // guards compile nothing must not pay for the compiler (`sass-lazy.test.ts`).
78
+ const { compileStylesheet } = await import('@ultimat3/render/server');
79
+ const { css, classes } = compileStylesheet(join(root, path), source);
80
+ return { css, classes };
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ };
85
+
86
+ return {
87
+ files(glob) {
88
+ let found = walks.get(glob);
89
+ if (found === undefined) {
90
+ found = walk(glob);
91
+ walks.set(glob, found);
92
+ }
93
+ return found;
94
+ },
95
+ compiled(path) {
96
+ let sheet = sheets.get(path);
97
+ if (sheet === undefined) {
98
+ sheet = compile(path);
99
+ sheets.set(path, sheet);
100
+ }
101
+ return sheet;
102
+ },
103
+ };
104
+ }