@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -0,0 +1,130 @@
1
+ // A registered code nothing constructs. The registry and the reference keep a shipped code alive
2
+ // forever — which is right — and that is exactly why a code can outlive its last thrower with no
3
+ // gate noticing: `X_RPC_FAILED` sat registered and documented as live after the transport change
4
+ // took its only throw site away. The reference row has to SAY so, or this reports it.
5
+
6
+ import { join } from 'node:path';
7
+ import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
8
+ import { RESERVED_HEADING } from './error-contract';
9
+ import type { Finding } from './output';
10
+ import { eachSourceFile, isGenerated, isTest } from './source-files';
11
+ import { isCodeRegistry } from './ts-scan';
12
+
13
+ const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
14
+ const TITLE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
15
+ /** A registry's own code LIST line — `'X_FOO',` — declares a code and throws nothing. */
16
+ const LIST_LINE = /^[\t ]*(['"`])X_[A-Z0-9_]+\1\s*,?\s*$/;
17
+ /** `metaMissing: 'X_SEO_META_MISSING'` — the table `@ultimat3/seo` and `@ultimat3/ui` raise from. */
18
+ const TABLE_ENTRY = /\b([a-z][A-Za-z0-9]*)\s*:\s*(['"`])(X_[A-Z0-9_]+)\2/g;
19
+ const MEMBER_READ = /\.([A-Za-z_$][\w$]*)/g;
20
+
21
+ /** Phrases a reference row uses to say, in words, that nothing throws the code any more. */
22
+ const DECLARED_UNTHROWN = /thrown by nothing|not thrown/i;
23
+
24
+ export interface CodeUse {
25
+ /** Every code a package registry names. */
26
+ readonly registered: ReadonlySet<string>;
27
+ /** Every code shipped source constructs, compares or raises outside a registry's declaration. */
28
+ readonly used: ReadonlySet<string>;
29
+ }
30
+
31
+ /**
32
+ * One file's contribution. A registry file's code LIST and title KEYS declare; any other literal in
33
+ * it is a use (a class's `code:`, a `?? 'X_…'` fallback, a comparison). A table entry counts only
34
+ * once something reads that member — which is how two packages raise every code they own.
35
+ */
36
+ export function codeUseOf(source: string): {
37
+ readonly registered: readonly string[];
38
+ readonly used: readonly string[];
39
+ readonly table: ReadonlyMap<string, string>;
40
+ readonly members: readonly string[];
41
+ } {
42
+ const text = stripComments(source);
43
+ const members = [...maskLiterals(source).matchAll(MEMBER_READ)].map((m) => m[1] ?? '');
44
+ if (!isCodeRegistry(text)) {
45
+ return {
46
+ registered: [],
47
+ used: [...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
48
+ table: new Map(),
49
+ members,
50
+ };
51
+ }
52
+ const registered = [
53
+ ...[...text.matchAll(TITLE_KEY)].map((m) => m[1] ?? ''),
54
+ ...[...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
55
+ ];
56
+ const table = new Map<string, string>();
57
+ for (const m of text.matchAll(TABLE_ENTRY)) {
58
+ // `code: 'X_…'` is a class or a factory constructing the code, never a table entry.
59
+ if (m[1] !== 'code') table.set(m[1] ?? '', m[3] ?? '');
60
+ }
61
+ const tableCodes = new Set(table.values());
62
+ const used: string[] = [];
63
+ for (const line of text.split('\n')) {
64
+ if (LIST_LINE.test(line)) continue;
65
+ for (const m of line.matchAll(CODE_LITERAL)) {
66
+ const code = m[2] ?? '';
67
+ if (!tableCodes.has(code)) used.push(code);
68
+ }
69
+ }
70
+ return { registered, used, table, members };
71
+ }
72
+
73
+ /** Shipped package source only: `scripts/` never ships, so a code only a gate script names is unthrown. */
74
+ export async function collectCodeUse(root: string): Promise<CodeUse> {
75
+ const registered = new Set<string>();
76
+ const used = new Set<string>();
77
+ const table = new Map<string, string>();
78
+ const members = new Set<string>();
79
+ for await (const source of eachSourceFile(root)) {
80
+ if (!/^packages\/[^/]+\/src\//.test(source) || isTest(source) || isGenerated(source)) continue;
81
+ const use = codeUseOf(await Bun.file(join(root, source)).text());
82
+ for (const code of use.registered) registered.add(code);
83
+ for (const code of use.used) used.add(code);
84
+ for (const [member, code] of use.table) table.set(member, code);
85
+ for (const member of use.members) members.add(member);
86
+ }
87
+ for (const [member, code] of table) if (members.has(member)) used.add(code);
88
+ return { registered, used };
89
+ }
90
+
91
+ /** Codes whose reference row says nothing throws them, or that sit under the reserved heading. */
92
+ export function declaredUnthrown(markdown: string): ReadonlySet<string> {
93
+ const out = new Set<string>();
94
+ let reserved = false;
95
+ for (const line of markdown.split('\n')) {
96
+ if (line.trim() === RESERVED_HEADING) reserved = true;
97
+ const row = /^\|\s*`(X_[A-Z0-9_]+)`\s*\|/.exec(line);
98
+ if (row !== null && (reserved || DECLARED_UNTHROWN.test(line))) out.add(row[1] ?? '');
99
+ }
100
+ return out;
101
+ }
102
+
103
+ const unthrownFinding = (code: string, page: string): Finding => ({
104
+ code: 'X_ERROR_CODE_UNTHROWN',
105
+ cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive`,
106
+ // Never "delete the registration": a shipped code is stable forever, and an old log line must
107
+ // still explain. The row is what has to change.
108
+ fix: `write "registered, thrown by nothing since <version>" into ${code}'s row in ${page}, naming the code that replaced it — or throw it again where it belongs`,
109
+ docs: ERROR_DOCS_URL,
110
+ at: page,
111
+ });
112
+
113
+ /**
114
+ * Registered, used by nothing, and not declared unthrown on the reference. A host check — the page
115
+ * is the host repo's to name, and only a monorepo's walk sees every package's source; in a
116
+ * generated app every framework code would read as unthrown.
117
+ */
118
+ export async function checkErrorCodesThrown(
119
+ root: string,
120
+ page: string,
121
+ ): Promise<readonly Finding[]> {
122
+ const reference = Bun.file(join(root, page));
123
+ if (!(await reference.exists())) return [];
124
+ const exempt = declaredUnthrown(await reference.text());
125
+ const { registered, used } = await collectCodeUse(root);
126
+ return [...registered]
127
+ .filter((code) => !used.has(code) && !exempt.has(code))
128
+ .sort()
129
+ .map((code) => unthrownFinding(code, page));
130
+ }
package/src/errors.ts CHANGED
@@ -72,6 +72,20 @@ export class AppNameIsPathError extends UltimateError {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * `x new '!!!'`: a name that slugifies to nothing names no directory, and the scaffold landed in
77
+ * the cwd itself — where `--force` then committed every file the user already had there.
78
+ */
79
+ export class AppNameEmptyError extends UltimateError {
80
+ constructor(input: { name: string; invocation: string; flags: readonly string[] }) {
81
+ super({
82
+ code: 'X_APP_NAME_EMPTY',
83
+ cause: `"${input.name}" has no letters or digits, so it names no directory`,
84
+ fix: [input.invocation, '<name-with-letters>', ...input.flags].join(' '),
85
+ });
86
+ }
87
+ }
88
+
75
89
  /**
76
90
  * A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
77
91
  *
@@ -287,35 +301,14 @@ export class FixTargetUnknownError extends UltimateError {
287
301
  }
288
302
  }
289
303
 
290
- /**
291
- * A build target names an entry file the app does not have. `x build` refuses before it spawns the
292
- * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
293
- * supposed to own, and `docker build`'s says nothing about which target wanted it.
294
- */
295
- export class BuildEntryMissingError extends UltimateError {
296
- constructor(input: { target: string; entry: string }) {
297
- super({
298
- code: 'X_BUILD_ENTRY_MISSING',
299
- cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
300
- fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
301
- });
302
- }
303
- }
304
-
305
- /**
306
- * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
307
- * entry point like any other, and the target's own logs are what says which line. The fix builds
308
- * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
309
- */
310
- export class IslandBuildFailedError extends UltimateError {
311
- constructor(input: { file: string; logs: string }) {
312
- super({
313
- code: 'X_BUILD_FAILED',
314
- cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
315
- fix: `bun build --target browser ${input.file}`,
316
- });
317
- }
318
- }
304
+ // The build and bundle refusals live in `build-errors.ts` (split at the 500-line ceiling); re-exported
305
+ // here so every existing `from './errors'` import keeps resolving.
306
+ export type { FrameworkScriptKind } from './build-errors';
307
+ export {
308
+ BuildEntryMissingError,
309
+ FrameworkScriptBuildFailedError,
310
+ IslandBuildFailedError,
311
+ } from './build-errors';
319
312
 
320
313
  /**
321
314
  * `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
package/src/favicon.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  // why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
7
7
  // it against the root is string work no `Bun.file` overload does — the same necessity
8
- // `dev-assets.ts` records for `ICON_SOURCE`.
8
+ // `runtime-assets.ts` records for `ICON_SOURCE`.
9
9
  import { join } from 'node:path';
10
10
  import { createRaster, encodeImage } from '@ultimat3/core';
11
11
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
@@ -69,7 +69,7 @@ const builtinBytes = (): Uint8Array => {
69
69
  const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
70
70
  applyCacheHeaders(
71
71
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
72
- // `SharedArrayBuffer`, which `Response` does not accept — `dev-assets.ts`'s rule, verbatim.
72
+ // `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
73
73
  new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
74
74
  FAVICON_CACHE,
75
75
  );
@@ -4,9 +4,9 @@
4
4
 
5
5
  // `dirname`/`join` are `node:`-only by necessity: Bun exposes no path-join primitive.
6
6
  import { dirname, join } from 'node:path';
7
+ import { endOfLiteral, maskLiterals } from '@ultimat3/core';
7
8
  import type { FixHelper } from './fix-scan';
8
9
  import { scanFixHelpers } from './fix-scan';
9
- import { endOfLiteral, maskLiterals } from './ts-scan';
10
10
 
11
11
  /**
12
12
  * A named import, matched over the MASKED source and anchored at the start of a line, so an
package/src/fix-scan.ts CHANGED
@@ -3,16 +3,9 @@
3
3
  // class's constructor. Split out of `ts-scan.ts` when the third shape and cross-file resolution
4
4
  // (`fix-imports.ts`) took the file past the 500-line ceiling; the masking primitives stay there.
5
5
 
6
+ import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
6
7
  import type { FixSite } from './ts-scan';
7
- import {
8
- CLOSERS,
9
- endOfLiteral,
10
- lineIndex,
11
- maskLiterals,
12
- OPENERS,
13
- QUOTES,
14
- valueLiterals,
15
- } from './ts-scan';
8
+ import { CLOSERS, lineIndex, OPENERS, valueLiterals } from './ts-scan';
16
9
 
17
10
  /** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
18
11
  const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
package/src/flag-reads.ts CHANGED
@@ -9,11 +9,10 @@
9
9
 
10
10
  // `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
11
11
  import { join, relative } from 'node:path';
12
- import { ERROR_DOCS_URL } from '@ultimat3/core';
12
+ import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
13
13
  import type { Finding } from './output';
14
14
  import type { CommandSpec, FlagSpec } from './parse';
15
15
  import { GLOBAL_FLAGS } from './parse';
16
- import { stripComments } from './ts-scan';
17
16
 
18
17
  /** A flag as declared, with the command that declares it. */
19
18
  export interface DeclaredFlag {
@@ -0,0 +1,36 @@
1
+ // The one fence for foreign text an agent reads: a PR review body (`x pr review`) and a CI log
2
+ // (`x ci`) are both written by anyone who can comment or push, and rendered bare they arrive in the
3
+ // agent's context indistinguishable from this CLI's own output. A leaf, so both commands share it.
4
+
5
+ const BLOCK_OPEN = '<comment id=';
6
+ const BLOCK_CLOSE = '</comment>';
7
+
8
+ /**
9
+ * The fence, as a READER would parse it rather than as this file spells it.
10
+ *
11
+ * Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
12
+ * `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
13
+ * tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
14
+ * around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
15
+ * the `<`, so what the reviewer wrote after it survives byte for byte.
16
+ */
17
+ const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
18
+
19
+ /**
20
+ * One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
21
+ * `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
22
+ * `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
23
+ * written by anyone who can comment on the pull request: rendered as bare indented text it arrived
24
+ * in that agent's context indistinguishable from the command's own output, which is prompt
25
+ * injection with a shell attached.
26
+ *
27
+ * The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
28
+ * wrote still reads, and the label is stripped of the three characters that would end the
29
+ * attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
30
+ * is data, and it can never stop one that decides otherwise.
31
+ */
32
+ export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
33
+ const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
34
+ const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
35
+ return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
36
+ }
@@ -78,12 +78,11 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
78
78
  /**
79
79
  * The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
80
80
  *
81
- * `packages/auth/src/tables.ts` exports them "so an app can paste them into a migration", and
82
- * nothing in the framework has ever applied them — while `x db gen` diffs `describeEntities()`
83
- * and these are not `entity()` declarations, so neither half was a file anybody could
84
- * hand-write. `examples/dummy/CLAUDE.md` records the consequence in its own words: nobody can
85
- * hold a session in the reference app. Applied here on exactly the rule the rate-limit and audit
86
- * rows already follow.
81
+ * Until this row nothing applied them, in dev or in production: they are not `entity()`
82
+ * declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
83
+ * into a migration nobody wrote — nobody could hold a session in the reference app. Those
84
+ * exports are gone; `AUTH_TABLES` is the whole DDL, the 1.3 `x_users` upgrade included, applied
85
+ * here on exactly the rule the rate-limit and audit rows already follow.
87
86
  *
88
87
  * `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
89
88
  * second copy is a second thing to keep right when a table is added.
@@ -0,0 +1,42 @@
1
+ // `x g <kind> --feature <f>` writes INTO a slice, and a slice that does not exist is refused rather
2
+ // than invented. It was invented: `x g task nightly --feature reports` laid down an
3
+ // `entity('reports', { title, price })` nobody asked for, and the `drift` step then demanded a
4
+ // migration for the table. Only `x g entity` and `x g resource` create a feature's data.
5
+
6
+ import { existsSync } from 'node:fs'; // why: a synchronous directory probe; Bun.file answers files, not directories.
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { Generator } from './generate-kinds';
9
+ import { containedPath } from './generate-write';
10
+ import { quoteArg } from './shell-quote';
11
+
12
+ /** The generators whose `--feature` names an existing slice to write into. */
13
+ const WRITES_INTO_SLICE: ReadonlySet<Generator> = new Set<Generator>([
14
+ 'action',
15
+ 'mutator',
16
+ 'query',
17
+ 'job',
18
+ 'task',
19
+ ]);
20
+
21
+ /** Declared beside its one thrower: `errors.ts` sits at its 500-line ceiling. */
22
+ export class FeatureUnknownError extends UltimateError {
23
+ constructor(input: { readonly feature: string; readonly slice: string }) {
24
+ super({
25
+ code: 'X_FEATURE_UNKNOWN',
26
+ cause: `--feature ${quoteArg(input.feature)} names ${input.slice}, which does not exist — a generator writing into a slice never invents one, or its entity table`,
27
+ fix: `x g resource ${quoteArg(input.feature)}`,
28
+ });
29
+ }
30
+ }
31
+
32
+ /** Refuses a `--feature` whose slice directory is absent, for the kinds that write into one. */
33
+ export function assertFeatureExists(
34
+ root: string,
35
+ kind: Generator,
36
+ feature: string | undefined,
37
+ slice: string,
38
+ ): void {
39
+ if (feature === undefined || !WRITES_INTO_SLICE.has(kind)) return;
40
+ if (existsSync(containedPath(root, slice))) return;
41
+ throw new FeatureUnknownError({ feature, slice });
42
+ }
@@ -56,8 +56,8 @@ export interface GenerateOptions {
56
56
  */
57
57
  readonly sliceErrors?: string;
58
58
  /**
59
- * `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when the feature has no
60
- * entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
59
+ * `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
60
+ * the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
61
61
  * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
62
62
  * `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
63
63
  * `sliceDir(surface, feature)/entity.ts`.
@@ -84,7 +84,9 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
84
84
  const surface: Surface = options.surface ?? 'app';
85
85
  assertSurfaceSupported(options.kind, surface, options.name);
86
86
  const surfaceDir = DEFAULT_SURFACE_DIR[surface];
87
- const feature = options.feature ?? options.name;
87
+ // Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
88
+ // `app/blog-post/`, so one feature grew two slice directories depending on the generator.
89
+ const feature = kebab(options.feature ?? options.name);
88
90
  const target = { surfaceDir, feature };
89
91
  switch (options.kind) {
90
92
  case 'resource':
@@ -101,6 +103,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
101
103
  actionFiles(options.name, {
102
104
  ...target,
103
105
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
106
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
104
107
  }),
105
108
  );
106
109
  case 'mutator':
@@ -109,6 +112,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
109
112
  ...target,
110
113
  mutator: true,
111
114
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
115
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
112
116
  }),
113
117
  );
114
118
  case 'backfill':
@@ -0,0 +1,83 @@
1
+ // `x g policy` / `x g resource` grant what they declare. A generated `policy.ts` declares
2
+ // `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
3
+ // answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
4
+ // `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
5
+
6
+ import { containedPath } from './generate-write';
7
+ import { ROLES_FILE } from './permission-grants';
8
+ import { wrapList } from './templates/wrap';
9
+
10
+ /** One permission to add to one role's `grants`. */
11
+ export interface RoleGrant {
12
+ readonly role: string;
13
+ readonly permission: string;
14
+ }
15
+
16
+ /** A generated policy file: `<surface>/<feature>/policy.ts`, never a test beside it. */
17
+ const POLICY_PATH = /^apps\/[^/]+\/[^/]+\/(?<feature>[a-z0-9-]+)\/policy\.ts$/;
18
+
19
+ /**
20
+ * The grants a written file set implies: the lowest role that makes sense for each verb. `member`
21
+ * reads, `admin` writes — the scaffold's dev actor is `admin`, so both reach it through
22
+ * `inherits`, and a `member` cookie still cannot write.
23
+ */
24
+ export function grantsForWritten(written: readonly string[]): readonly RoleGrant[] {
25
+ return written.flatMap((path) => {
26
+ const feature = POLICY_PATH.exec(path)?.groups?.['feature'];
27
+ return feature === undefined
28
+ ? []
29
+ : [
30
+ { role: 'member', permission: `${feature}:read` },
31
+ { role: 'admin', permission: `${feature}:write` },
32
+ ];
33
+ });
34
+ }
35
+
36
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+
38
+ /**
39
+ * `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints
40
+ * it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in
41
+ * `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be
42
+ * a second, worse answer.
43
+ */
44
+ export function insertGrants(
45
+ source: string,
46
+ grants: readonly RoleGrant[],
47
+ ): { readonly source: string; readonly skipped: readonly RoleGrant[] } {
48
+ let next = source;
49
+ const skipped: RoleGrant[] = [];
50
+ for (const grant of grants) {
51
+ const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next);
52
+ const open = role === null ? -1 : next.indexOf('grants: [', role.index);
53
+ const close = open === -1 ? -1 : next.indexOf(']', open);
54
+ const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index);
55
+ if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) {
56
+ skipped.push(grant);
57
+ continue;
58
+ }
59
+ const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
60
+ if (current.includes(grant.permission)) continue;
61
+ const entries = [...current, grant.permission].map((permission) => `'${permission}'`);
62
+ const lineStart = next.lastIndexOf('\n', open) + 1;
63
+ const indent = next.slice(lineStart, open);
64
+ const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
65
+ next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
66
+ }
67
+ return { source: next, skipped };
68
+ }
69
+
70
+ /** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
71
+ export async function grantGeneratedPermissions(
72
+ root: string,
73
+ written: readonly string[],
74
+ ): Promise<readonly string[]> {
75
+ const grants = grantsForWritten(written);
76
+ const file = containedPath(root, ROLES_FILE);
77
+ if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
78
+ const before = await Bun.file(file).text();
79
+ const { source } = insertGrants(before, grants);
80
+ if (source === before) return [];
81
+ await Bun.write(file, source);
82
+ return [ROLES_FILE];
83
+ }
@@ -10,6 +10,7 @@ import {
10
10
  UnknownCommandError,
11
11
  } from './errors';
12
12
  import type { Surface } from './templates';
13
+ import { camel, kebab } from './templates/naming';
13
14
 
14
15
  export const GENERATORS = [
15
16
  'resource',
@@ -106,11 +107,62 @@ export function readSurface(raw: string | undefined, kind: Generator, name: stri
106
107
  * is a command that runs.
107
108
  */
108
109
  export function readName(raw: string | undefined, kind: Generator): string {
109
- if (raw !== undefined) return raw;
110
- throw new MissingPositionalError({
110
+ if (raw === undefined) {
111
+ throw new MissingPositionalError({
112
+ command: `g ${kind}`,
113
+ positional: 'name',
114
+ example: exampleFor(kind),
115
+ });
116
+ }
117
+ refusePath(raw, 'name', kind);
118
+ refuseBadIdentifier(raw, kind);
119
+ return raw;
120
+ }
121
+
122
+ /** `--feature`, refused where it is a path: it is a DIRECTORY under the surface, one segment. */
123
+ export function readFeature(raw: string | undefined, kind: Generator): string | undefined {
124
+ if (raw !== undefined) refusePath(raw, 'feature', kind);
125
+ return raw;
126
+ }
127
+
128
+ /** A name is one directory segment: `x g action ../../../evil` wrote outside `apps/`. */
129
+ function refusePath(raw: string, flag: string, kind: Generator): void {
130
+ if (!/[\\/]/.test(raw) && raw.trim() !== '..' && raw.trim() !== '.') return;
131
+ throw new BadFlagError({
132
+ flag,
133
+ command: `g ${kind}`,
134
+ reason: `"${raw}" is a path, and a generator ${flag} is one directory segment under the surface`,
135
+ fix: exampleFor(kind),
136
+ });
137
+ }
138
+
139
+ /** Strict-mode reserved words: `export const delete = …` does not parse. */
140
+ const RESERVED = new Set(
141
+ (
142
+ 'break case catch class const continue debugger default delete do else enum export extends ' +
143
+ 'false finally for function if import in instanceof new null return super switch this throw ' +
144
+ 'true try typeof var void while with yield let static implements interface package private ' +
145
+ 'protected public await arguments eval'
146
+ ).split(' '),
147
+ );
148
+
149
+ /**
150
+ * The name becomes `camel(name)` in emitted source, so it has to be an identifier there: not a
151
+ * reserved word, not starting with a digit. The fix is the same run with a name that is one — the
152
+ * kind appended to a reserved word, prepended to a leading digit.
153
+ */
154
+ function refuseBadIdentifier(raw: string, kind: Generator): void {
155
+ const identifier = camel(raw);
156
+ const digit = /^[0-9]/.test(identifier);
157
+ if (identifier !== '' && !digit && !RESERVED.has(identifier)) return;
158
+ const noun = kind.split(':').at(-1) ?? kind;
159
+ const suggestion = digit ? `${noun}-${kebab(raw)}` : `${kebab(raw)}-${noun}`;
160
+ throw new BadFlagError({
161
+ flag: 'name',
111
162
  command: `g ${kind}`,
112
- positional: 'name',
113
- example: exampleFor(kind),
163
+ reason: `"${raw}" becomes the identifier "${identifier}" in generated source, which ${digit ? 'starts with a digit' : identifier === '' ? 'is empty' : 'is a reserved word'}`,
164
+ // Joined, never spliced: the suggestion is kebab output, and the rule reads the shape.
165
+ fix: ['x g', kind, suggestion].join(' '),
114
166
  });
115
167
  }
116
168
 
package/src/i18n-index.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  // whether to write at all, before any await the caller could interleave with.
11
11
  import { existsSync } from 'node:fs';
12
12
  import { containedPath } from './generate-write';
13
- import { CATALOG_ROOT, i18nIndex } from './templates';
13
+ import type { Finding } from './output';
14
+ import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates';
14
15
 
15
16
  export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
16
17
 
@@ -25,16 +26,64 @@ export async function catalogLocales(root: string): Promise<readonly string[]> {
25
26
  return locales.sort();
26
27
  }
27
28
 
29
+ /** What a sync did: whether every catalog on disk is now selectable, and why not when it is not. */
30
+ export interface IndexSync {
31
+ /** False for an app with no i18n package, or an index this writer refused to edit. */
32
+ readonly registered: boolean;
33
+ readonly findings: readonly Finding[];
34
+ }
35
+
36
+ const CATALOG_IMPORT = /^import (\w+) from '\.\.\/catalogs\/([^']+)\.json';$/gm;
37
+ const LOCALES_OBJECT = /(locales:\s*\{)([^{}]*)(\})/;
38
+
39
+ /** The tags the index imports a catalog for, in file order. */
40
+ const importedLocales = (source: string): readonly string[] =>
41
+ [...source.matchAll(CATALOG_IMPORT)].map((match) => match[2] ?? '');
42
+
28
43
  /**
29
- * Re-derives the FULL locale set from `packages/i18n/catalogs/` — never just the locale one
30
- * invocation asked for — and rewrites the index to match. It bypasses `writeFiles` on purpose:
31
- * this file is a projection of the catalog directory, never app-authored content a conflict check
32
- * should protect. An app with no i18n package (deleted, or never scaffolded) is left alone, and
33
- * that is what `written` reports.
44
+ * Makes every catalog on disk selectable, and touches the index no more than that takes.
45
+ *
46
+ * An index that IS the template's output (for the locales it imports) is the framework's file and
47
+ * is re-derived whole. Anything else is the author's: each missing locale gets its import and its
48
+ * `locales: { … }` entry and nothing else moves — the declared `default`, and any code beside it,
49
+ * stay. A shape with no `locales: { … }` object to add to is refused with the edit named, never
50
+ * overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`.
34
51
  */
35
- export async function syncI18nIndex(root: string): Promise<boolean> {
52
+ export async function syncI18nIndex(root: string): Promise<IndexSync> {
36
53
  const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
37
- if (!existsSync(indexAbsolute)) return false;
38
- await Bun.write(indexAbsolute, i18nIndex(await catalogLocales(root)));
39
- return true;
54
+ if (!existsSync(indexAbsolute)) return { registered: false, findings: [] };
55
+ const current = await Bun.file(indexAbsolute).text();
56
+ const onDisk = await catalogLocales(root);
57
+ const imported = importedLocales(current);
58
+ if (current === i18nIndex(imported)) {
59
+ await Bun.write(indexAbsolute, i18nIndex(onDisk));
60
+ return { registered: true, findings: [] };
61
+ }
62
+ const missing = onDisk.filter((locale) => !imported.includes(locale));
63
+ if (missing.length === 0) return { registered: true, findings: [] };
64
+ const edited = withLocales(current, missing);
65
+ if (edited === undefined) return { registered: false, findings: [refusal(missing)] };
66
+ await Bun.write(indexAbsolute, edited);
67
+ return { registered: true, findings: [] };
68
+ }
69
+
70
+ /** Each import after the last catalog import (or at the top), each entry at the object's end. */
71
+ function withLocales(source: string, locales: readonly string[]): string | undefined {
72
+ const object = LOCALES_OBJECT.exec(source);
73
+ if (object === null) return undefined;
74
+ const body = (object[2] ?? '').trim().replace(/,$/, '');
75
+ const entries = [body, ...locales.map(localeEntry)].filter((part) => part !== '').join(', ');
76
+ const withEntries = source.replace(LOCALES_OBJECT, `$1 ${entries} $3`);
77
+ const imports = locales.map(localeImport).join('\n');
78
+ const lastImport = [...withEntries.matchAll(CATALOG_IMPORT)].at(-1);
79
+ if (lastImport === undefined) return `${imports}\n${withEntries}`;
80
+ const at = lastImport.index + lastImport[0].length;
81
+ return `${withEntries.slice(0, at)}\n${imports}${withEntries.slice(at)}`;
40
82
  }
83
+
84
+ const refusal = (locales: readonly string[]): Finding => ({
85
+ code: 'X_CATALOG_UNREGISTERED',
86
+ cause: `${I18N_INDEX_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
87
+ fix: `edit ${I18N_INDEX_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
88
+ at: I18N_INDEX_PATH,
89
+ });
@@ -1,6 +1,6 @@
1
1
  // Single responsibility: the one source image every generated icon derives from, where the matrix
2
2
  // is served, and the renderer that turns one into the other. Its own module so the two things that
3
- // need it — `dev-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
3
+ // need it — `runtime-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
4
4
  // web manifest — can share it without importing each other.
5
5
 
6
6
  // why: Bun exposes no path-join primitive, and `ICON_SOURCE` is app-root-relative, so resolving it