@ultimat3/cli 1.1.0 → 2.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 (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -0,0 +1,64 @@
1
+ // OTLP export, switched on by the variable the shipped Helm chart already sets. Until this file
2
+ // `OTEL_EXPORTER_OTLP_ENDPOINT` was in `docker/helm/values.yaml` and **no code read it**: a
3
+ // deployment configured a collector, the collector received nothing, and the only signal that
4
+ // anything was wrong was an empty dashboard.
5
+
6
+ import {
7
+ configureMetrics,
8
+ configureTelemetry,
9
+ logger,
10
+ onShutdown,
11
+ otlpMetricExporter,
12
+ otlpSpanExporter,
13
+ startMetricExport,
14
+ tryOtlpEndpoint,
15
+ } from '@ultimat3/core';
16
+ import type { Env } from './dev-services';
17
+
18
+ /** How often counters are pushed. Core's own default; named here because the boot chose it. */
19
+ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
20
+
21
+ /**
22
+ * Install whichever exporters an endpoint was configured for, and return the release.
23
+ *
24
+ * `tryOtlpEndpoint` is asked FIRST, per signal, because both constructors throw
25
+ * `X_OTLP_ENDPOINT_INVALID` when nothing configured one — deliberately, so that a telemetry
26
+ * exporter can never silently send nowhere. Asking is what makes the exporter optional without
27
+ * making it silent.
28
+ *
29
+ * Both are registered with `onShutdown(..., { phase: 'close' })`: the last spans of a drain are
30
+ * the ones that explain the drain, and a process that exits with a full queue loses exactly the
31
+ * window an operator went looking for.
32
+ */
33
+ export function startOtlpExport(env: Env = process.env): () => void {
34
+ const releases: (() => void)[] = [];
35
+
36
+ // The boot's OWN env, not `process.env`, and the resolved endpoint is then passed to the
37
+ // exporter explicitly: `runRole({ env })` is a real seam — a test and an in-process host both
38
+ // pass an env that is not the process's — and an exporter that re-read `process.env` would
39
+ // answer a different question from the one this function just asked.
40
+ const traces = tryOtlpEndpoint('traces', env);
41
+ if (traces !== undefined) {
42
+ const exporter = otlpSpanExporter({ endpoint: traces });
43
+ configureTelemetry({ exporter });
44
+ releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
45
+ logger.info('ultimate otlp traces', { endpoint: traces });
46
+ }
47
+
48
+ const metrics = tryOtlpEndpoint('metrics', env);
49
+ if (metrics !== undefined) {
50
+ const exporter = otlpMetricExporter({ endpoint: metrics });
51
+ configureMetrics({ exporter });
52
+ // The push loop, and not only the exporter: `configureMetrics` names where a snapshot goes
53
+ // and nothing decides when one is taken, so without this the collector receives one export —
54
+ // the drain's — for the whole life of the process.
55
+ const stopTimer = startMetricExport(METRIC_EXPORT_INTERVAL_MS);
56
+ releases.push(stopTimer);
57
+ releases.push(onShutdown('otlp-metrics', () => exporter.flush(), { phase: 'close' }));
58
+ logger.info('ultimate otlp metrics', { endpoint: metrics });
59
+ }
60
+
61
+ return () => {
62
+ for (const release of releases.reverse()) release();
63
+ };
64
+ }
package/src/output.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  // and the JSON renderer are projections of it, so `--json` can never drift from the terminal
3
3
  // output (axiom 4). The human renderer owns the canonical 3-line error format.
4
4
 
5
+ import { renderThrowable, stringField } from '@ultimat3/core';
6
+ import { msg } from './messages';
7
+
5
8
  export interface Finding {
6
9
  readonly code: string;
7
10
  readonly cause: string;
@@ -19,6 +22,8 @@ export interface StepResult {
19
22
  readonly findings: readonly Finding[];
20
23
  /** Captured stdout/stderr, shown on failure or with --verbose. */
21
24
  readonly output?: string;
25
+ /** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
26
+ readonly workers?: number;
22
27
  }
23
28
 
24
29
  export type JsonValue =
@@ -57,34 +62,39 @@ export interface UltimateErrorShape {
57
62
  readonly message: string;
58
63
  }
59
64
 
60
- const isRecord = (value: unknown): value is Record<string, unknown> =>
61
- typeof value === 'object' && value !== null;
62
-
63
65
  /**
64
66
  * Structural check, deliberately not `instanceof`: an error may cross a subprocess or worker
65
67
  * boundary and arrive as a plain object, and the renderer must still produce the fix line.
68
+ *
69
+ * Every field goes through core's `stringField`, because the value is a caught throwable and the
70
+ * probe itself is a property read: a getter that throws, or a `Proxy` trapping `get`, raised HERE
71
+ * — one line before the total renderer below, in the function whose whole job is deciding what the
72
+ * terminal shows.
66
73
  */
67
74
  export function isUltimateErrorShape(value: unknown): value is UltimateErrorShape {
68
- if (!isRecord(value)) return false;
69
75
  return (
70
- typeof value['code'] === 'string' &&
71
- value['code'].startsWith('X_') &&
72
- typeof value['cause'] === 'string' &&
73
- typeof value['fix'] === 'string'
76
+ stringField(value, 'code')?.startsWith('X_') === true &&
77
+ stringField(value, 'cause') !== undefined &&
78
+ stringField(value, 'fix') !== undefined
74
79
  );
75
80
  }
76
81
 
77
82
  export function findingFrom(value: unknown): Finding {
78
- if (isUltimateErrorShape(value)) {
79
- const docs = value.docs;
80
- return docs === undefined
81
- ? { code: value.code, cause: value.cause, fix: value.fix }
82
- : { code: value.code, cause: value.cause, fix: value.fix, docs };
83
+ // Read once and carry the values, rather than narrowing and reading the same properties again:
84
+ // a getter is a function, and nothing promises it answers the same way twice.
85
+ const code = stringField(value, 'code');
86
+ const cause = stringField(value, 'cause');
87
+ const fix = stringField(value, 'fix');
88
+ if (code?.startsWith('X_') === true && cause !== undefined && fix !== undefined) {
89
+ const docs = stringField(value, 'docs');
90
+ return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
83
91
  }
84
- const cause = value instanceof Error ? value.message : String(value);
92
+ // This is the LAST renderer between a thrown value and the terminal: a hostile `toString`, a
93
+ // throwing `message` getter or a trapped `instanceof` here loses the whole report, not one line
94
+ // of it. `renderThrowable` is total on all three.
85
95
  return {
86
96
  code: 'X_CLI_UNEXPECTED',
87
- cause,
97
+ cause: renderThrowable(value),
88
98
  fix: 'x doctor --json',
89
99
  docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
90
100
  };
@@ -130,10 +140,20 @@ const mark = (step: StepResult): string => {
130
140
  return step.ok ? '✓' : '✗';
131
141
  };
132
142
 
143
+ /**
144
+ * How the step was run, when that is a fact about the step rather than about the machine. A gate
145
+ * that silently went parallel is a gate whose failures a reader would blame on flakiness, so a
146
+ * serial step says so and a parallel one names its width.
147
+ */
148
+ const width = (step: StepResult): string => {
149
+ if (step.workers === undefined || step.skipped === true) return '';
150
+ return ` ${step.workers === 1 ? msg('cli.verify.serial') : msg('cli.verify.workers', { workers: step.workers })}`;
151
+ };
152
+
133
153
  export function renderHuman(result: CommandResult, verbose = false): string {
134
154
  const out: string[] = [];
135
155
  for (const step of result.steps ?? []) {
136
- out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms`);
156
+ out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}`);
137
157
  for (const finding of step.findings) out.push(renderFinding(finding, ' '));
138
158
  if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
139
159
  for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
@@ -152,6 +172,16 @@ export function renderJson(result: CommandResult): string {
152
172
  durationMs: step.durationMs,
153
173
  skipped: step.skipped === true,
154
174
  findings: step.findings,
175
+ ...(step.workers === undefined ? {} : { workers: step.workers }),
176
+ // A FAILED step carries its captured stdout, exactly as the human renderer prints it. CI runs
177
+ // `--json`, and without this the log said only "one or more unit tests failed" with a generic
178
+ // fix line — the failing test's name and its assertion diff existed and were thrown away, so
179
+ // the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
180
+ // whoever reads it next, agent or human, must be able to act without reproducing first.
181
+ // Success stays quiet (`--verbose` is the human's opt-in) so a green run is not a wall of text.
182
+ ...(step.ok || step.output === undefined || step.output.length === 0
183
+ ? {}
184
+ : { output: step.output }),
155
185
  }));
156
186
  const payload = {
157
187
  ok: result.ok,
package/src/parse.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
3
3
  // access, so the parser is unit-testable and the dispatcher owns all side effects.
4
4
 
5
- import { BadFlagError, UnknownCommandError } from './errors';
5
+ import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
6
6
 
7
7
  export type FlagValue = string | boolean;
8
8
 
@@ -20,6 +20,32 @@ export interface CommandSpec {
20
20
  readonly usage: string;
21
21
  readonly aliases?: readonly string[];
22
22
  readonly subcommands?: readonly string[];
23
+ /**
24
+ * What a bare `x <command>` means, when it means anything. Declared, never inferred: the parser
25
+ * used to answer `subcommands[0]`, so `x db` ran `gen` — the migration GENERATOR — because it
26
+ * sorted first. A command with no defensible default omits this and the parser refuses instead.
27
+ */
28
+ readonly defaultSubcommand?: string;
29
+ /**
30
+ * A closed set the FIRST positional must come from, where the command has one. Declarative only:
31
+ * the parser leaves positionals to the command, because `x test`'s own `readOnlyType` already
32
+ * refuses an unknown type with the list. What this adds is a set `fix-command.ts` can resolve a
33
+ * citation against. `packages/ai/src/eval-errors.ts` avoids `x test <eval-name>` by hand, with a
34
+ * three-line comment explaining that it is `X_CLI_BAD_FLAG` — a convention held by memory,
35
+ * because nothing outside `cmd-test.ts` knew what `x test` accepts. Declare it from the SAME
36
+ * constant the command validates against, never a second literal.
37
+ */
38
+ readonly positionalChoices?: readonly string[];
39
+ /**
40
+ * The same closed set, one level down: the set a named SUBCOMMAND's first positional must come
41
+ * from. `positionalChoices` cannot express it, because `fix-command.ts` only consults that field
42
+ * where a command declares NO subcommands — so `x db branch ls` resolved as command +
43
+ * subcommand and nothing ever looked at `ls`. That is how a shipped `fix:` told an agent to run
44
+ * a listing while `x db branch` read `ls` as a branch name and created a database from it.
45
+ * Declarative only, exactly like `positionalChoices`: the command still refuses an unknown word
46
+ * itself, and this is declared from the SAME constant it validates against.
47
+ */
48
+ readonly subcommandPositionals?: Readonly<Record<string, readonly string[]>>;
23
49
  readonly flags?: readonly FlagSpec[];
24
50
  /** Command needs an app root (`app.config.ts`) — the dispatcher enforces it. */
25
51
  readonly requiresApp?: boolean;
@@ -44,6 +70,15 @@ export const GLOBAL_FLAGS: readonly FlagSpec[] = [
44
70
  { name: 'verbose', type: 'boolean', summary: 'include step output on success' },
45
71
  ];
46
72
 
73
+ /**
74
+ * Whether raw argv asked for JSON, readable BEFORE the parse succeeds. The parse-failure path in
75
+ * `dispatch.ts` has no `ParsedArgs` to read `--json` off — and it used to test `argv.includes`
76
+ * for the long form only, so `x doctor -j --bogusflag` rendered its `X_CLI_BAD_FLAG` as prose to
77
+ * an agent that had asked for JSON and then called `JSON.parse` on it. One detection, two callers.
78
+ */
79
+ export const wantsJson = (argv: readonly string[]): boolean =>
80
+ argv.some((token) => token === '--json' || token === '-j');
81
+
47
82
  const HELP_ALIASES = new Set(['--help', '-h', 'help']);
48
83
  const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
49
84
 
@@ -118,7 +153,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
118
153
  // `help` and `version` short-circuit the flag loop below, so `--json` has to be read here or the
119
154
  // two commands silently print prose to an agent that asked for JSON — and every `fix:` naming
120
155
  // `x help --json` would be a command that does not do what it says.
121
- const json = tokens.some((token) => token === '--json' || token === '-j');
156
+ const json = wantsJson(tokens);
122
157
  if (VERSION_ALIASES.has(first)) return blank('version', specs, json);
123
158
  if (HELP_ALIASES.has(first)) {
124
159
  const rest = tokens.slice(1).filter((token) => !token.startsWith('-'));
@@ -198,7 +233,10 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
198
233
  const allowed = spec.subcommands;
199
234
  if (allowed === undefined || allowed.length === 0) return undefined;
200
235
  const token = positionals[0];
201
- if (token === undefined) return allowed[0];
236
+ if (token === undefined) {
237
+ if (spec.defaultSubcommand !== undefined) return spec.defaultSubcommand;
238
+ throw new MissingSubcommandError({ command: spec.name, known: allowed });
239
+ }
202
240
  if (allowed.includes(token)) return token;
203
241
  const suggestion = nearest(token, allowed);
204
242
  throw new UnknownCommandError(
@@ -12,12 +12,30 @@ import { devActors } from './dev-policy';
12
12
 
13
13
  const isDefined = <T>(value: T | undefined): value is T => value !== undefined;
14
14
 
15
- /** Descriptor names whose `capability` is exactly this permission — shared by list and explain. */
16
- const namesEnforcing = <D extends { readonly name: string; readonly capability: string }>(
15
+ /**
16
+ * Descriptor names whose policy references this permission — shared by list and explain.
17
+ *
18
+ * Matched on `permissions` and never on `capability`. `capability` is the DISPLAY label, and a
19
+ * composite policy renders as `and(post:publish, org:administer)` — never a bare permission — so
20
+ * an equality test against it reported every action guarded by a composite as enforcing nothing.
21
+ * That is `unenforced`: "this grant does nothing", printed about every non-trivial rule in a real
22
+ * app, to the compliance engineer reading it before an access review.
23
+ */
24
+ const namesEnforcing = <
25
+ D extends { readonly name: string; readonly permissions: readonly string[] },
26
+ >(
17
27
  descriptors: readonly D[],
18
28
  permission: string,
19
29
  ): readonly string[] =>
20
- descriptors.filter((descriptor) => descriptor.capability === permission).map((d) => d.name);
30
+ descriptors
31
+ .filter((descriptor) => descriptor.permissions.includes(permission))
32
+ .map((d) => d.name);
33
+
34
+ /** The same rule as `namesEnforcing`, for the two `explain` filters that keep the descriptor. */
35
+ const enforces = (
36
+ descriptor: { readonly permissions: readonly string[] },
37
+ permission: string,
38
+ ): boolean => descriptor.permissions.includes(permission);
21
39
 
22
40
  // ── list ──────────────────────────────────────────────────────────────────
23
41
 
@@ -66,7 +84,14 @@ export type SubjectKind = 'permission' | DeclarationKind;
66
84
  export interface DeclarationExplanation {
67
85
  readonly name: string;
68
86
  readonly kind: DeclarationKind;
87
+ /** The policy's DISPLAY label — `and(post:publish, org:administer)` for a composite. */
69
88
  readonly capability: string;
89
+ /**
90
+ * Every permission the policy tree references, flattened. What a grant is MATCHED against; the
91
+ * label above is what a person reads. Published because `grantingRoles` is derived from it —
92
+ * `rolesGranting('and(a:b, c:d)')` is a lookup that can only ever answer nothing.
93
+ */
94
+ readonly permissions: readonly string[];
70
95
  readonly label: string;
71
96
  /**
72
97
  * Whether this policy can be decided at all outside a request. `false` when evaluating it
@@ -117,6 +142,7 @@ const explainAction = (action: AnyAction): DeclarationExplanation => {
117
142
  name: descriptor.name,
118
143
  kind: 'action',
119
144
  capability: descriptor.capability,
145
+ permissions: descriptor.permissions,
120
146
  label: action.policy.label,
121
147
  ...matrixFor(action.policy),
122
148
  };
@@ -128,6 +154,7 @@ const explainQuery = (query: AnyQuery): DeclarationExplanation => {
128
154
  name: descriptor.name,
129
155
  kind: 'query',
130
156
  capability: descriptor.capability,
157
+ permissions: descriptor.permissions,
131
158
  label: query.policy.label,
132
159
  ...matrixFor(query.policy),
133
160
  };
@@ -168,12 +195,12 @@ export function explainPolicy(name: string): SubjectExplanation | undefined {
168
195
  const { permission } = resolved;
169
196
  const declarations = [
170
197
  ...describeActions()
171
- .filter((descriptor) => descriptor.capability === permission)
198
+ .filter((descriptor) => enforces(descriptor, permission))
172
199
  .map((descriptor) => getAction(descriptor.name))
173
200
  .filter(isDefined)
174
201
  .map(explainAction),
175
202
  ...describeQueries()
176
- .filter((descriptor) => descriptor.capability === permission)
203
+ .filter((descriptor) => enforces(descriptor, permission))
177
204
  .map((descriptor) => getQuery(descriptor.name))
178
205
  .filter(isDefined)
179
206
  .map(explainQuery),
@@ -190,7 +217,12 @@ export function explainPolicy(name: string): SubjectExplanation | undefined {
190
217
  return {
191
218
  subject: name,
192
219
  kind: resolved.kind,
193
- grantingRoles: rolesGranting(declaration.capability),
220
+ // The union over every permission the policy references, not a lookup on the label: a
221
+ // composite-guarded action reported NO granting roles at all, which reads as "nobody can do
222
+ // this" about a declaration half the roles in the app can reach.
223
+ grantingRoles: [
224
+ ...new Set(declaration.permissions.flatMap((permission) => rolesGranting(permission))),
225
+ ].sort(),
194
226
  declarations: [declaration],
195
227
  };
196
228
  }
@@ -15,19 +15,26 @@ interface PostRow {
15
15
  }
16
16
 
17
17
  /**
18
- * Three permissions, three roles, two actions and two queries — each fact earning its place.
18
+ * Four permissions, three roles, two actions and two queries — each fact earning its place.
19
19
  *
20
- * `post:read` is declared but enforced by nothing: `archivePost`'s policy grants that permission
21
- * to its second clause, but the compound policy's own capability is `and(post:publish, post:read)`
22
- * — a different string — so `post:read` alone stays unenforced. `post:publish` is enforced by an
23
- * action AND a query at once, so the aggregation across declarations has something to aggregate.
20
+ * `archivePost` is guarded by a COMPOSITE, and that is the point of it: its display capability is
21
+ * the label `and(post:publish, post:read)`, which is not any permission, so a report that matched
22
+ * on the label counted this action as enforcing nothing and printed both of its permissions as
23
+ * dead grants. It enforces two, and the facts say so.
24
+ *
25
+ * `post:delete` is the control: declared, granted to `admin`, enforced by nothing at all. It is
26
+ * what a genuinely dead grant looks like, and it is what keeps `unenforced` a claim worth making
27
+ * now that a composite no longer lands in it by accident.
28
+ *
29
+ * `post:publish` is enforced by two actions AND a query, so the aggregation across declarations
30
+ * has something to aggregate.
24
31
  *
25
32
  * Registers only; the registries are process-global, so the caller clears them first.
26
33
  */
27
34
  export function registerPolicyFixture(): void {
28
- definePermissions(['post:publish', 'post:read', 'feed:read'] as const);
35
+ definePermissions(['post:publish', 'post:read', 'post:delete', 'feed:read'] as const);
29
36
  defineRoles({
30
- admin: { grants: ['post:publish', 'post:read', 'feed:read'] },
37
+ admin: { grants: ['post:publish', 'post:read', 'post:delete', 'feed:read'] },
31
38
  editor: { grants: ['post:publish', 'post:read'] },
32
39
  reader: { grants: ['post:read', 'feed:read'] },
33
40
  });
package/src/prerender.ts CHANGED
@@ -4,11 +4,16 @@
4
4
  // only which routes qualify and where the bytes land.
5
5
 
6
6
  import { join } from 'node:path';
7
+ import { renderThrowable } from '@ultimat3/core';
7
8
  import type { RouteEntry } from '@ultimat3/render';
8
9
  import { renderStatic, routeEntries } from '@ultimat3/render';
9
10
  import { loadApp } from './app-load';
10
11
  import { appManifest } from './app-manifest';
12
+ import type { RouteStats } from './budgets';
13
+ import { measureDocumentJs, writeBuildStats } from './budgets';
11
14
  import { routeDocument } from './dev-render';
15
+ import type { IslandBundle } from './island-bundle';
16
+ import { buildIslands, writeIslands } from './island-bundle';
12
17
 
13
18
  /**
14
19
  * `static` only. `isr` revalidates and `ssr`/`stream`/`spa` need a process, so writing any of them
@@ -32,16 +37,66 @@ export interface PrerenderedPage {
32
37
  readonly bytes: number;
33
38
  }
34
39
 
40
+ /** A route that declared a budget and could not be rendered here, and what stopped it. */
41
+ export interface UnmeasuredRoute {
42
+ readonly path: string;
43
+ readonly reason: string;
44
+ }
45
+
35
46
  export interface PrerenderReport {
36
47
  readonly out: string;
37
48
  readonly buildId: string;
38
49
  readonly pages: readonly PrerenderedPage[];
39
50
  /** Routes that exist and are not static. Reported, so "only 2 pages" is never a mystery. */
40
51
  readonly skipped: readonly string[];
52
+ /**
53
+ * Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
54
+ * the gate then reports for each; this is the half that says WHY, which a per-route finding read
55
+ * off a stats file cannot know.
56
+ */
57
+ readonly unmeasured: readonly UnmeasuredRoute[];
58
+ /** Where the measured stats landed, for the `budgets` gate step to read. */
59
+ readonly stats: string;
60
+ /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
61
+ readonly islands: readonly string[];
62
+ }
63
+
64
+ /**
65
+ * The heaviest thing the document actually boots, named by the source file an author can open.
66
+ * An island chunk is content-addressed, so its URL says nothing on its own — mapping it back
67
+ * through the bundle is what turns `X_BUDGET_EXCEEDED` from a number into an instruction.
68
+ */
69
+ function heaviestSource(
70
+ bundle: IslandBundle,
71
+ entries: readonly { readonly url: string; readonly bytes: number }[],
72
+ ): readonly string[] | undefined {
73
+ const heaviest = entries.reduce<{ url: string; bytes: number } | undefined>(
74
+ (max, entry) => (max === undefined || entry.bytes > max.bytes ? entry : max),
75
+ undefined,
76
+ );
77
+ if (heaviest === undefined) return undefined;
78
+ const chunk = bundle.chunkAt(heaviest.url);
79
+ return chunk === undefined ? [heaviest.url] : [chunk.file];
41
80
  }
42
81
 
43
82
  export const DEFAULT_ORIGIN = 'https://localhost';
44
83
 
84
+ /**
85
+ * Prerendering and measuring are two questions, and conflating them made `X_BUDGET_UNMEASURED`
86
+ * unclosable by any invocation: only `static` was ever rendered, so a `budget:` on an ssr, isr,
87
+ * stream or spa route produced no `build-stats.json` entry however the build was run, and a gate
88
+ * whose finding no command can close is a gate an author learns to ignore.
89
+ *
90
+ * The first question decides what lands on a CDN — `isPrerenderable`, unchanged, because a page
91
+ * whose staleness nothing can correct must not be published. The second asks what a browser
92
+ * executes, and every render mode makes that promise. So a budgeted route is rendered IN MEMORY
93
+ * through the same `routeDocument` a request takes, weighed, and thrown away.
94
+ */
95
+ const declaresBudget = (entry: RouteEntry): boolean => {
96
+ const budget = entry.config.budget;
97
+ return budget !== undefined && (budget.js !== undefined || budget.lcp !== undefined);
98
+ };
99
+
45
100
  export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
46
101
  // The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
47
102
  // route registry, so there is no route table to prerender before this runs.
@@ -50,22 +105,76 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
50
105
  const origin = options.origin ?? DEFAULT_ORIGIN;
51
106
  const pages: PrerenderedPage[] = [];
52
107
  const skipped: string[] = [];
108
+ const routes: RouteStats[] = [];
109
+ const unmeasured: UnmeasuredRoute[] = [];
110
+
111
+ // Before the first document: a page's `data-x-entry` is a built chunk's URL, so the chunks have
112
+ // to exist to be named. Written into `out` too — a static export is served with no process
113
+ // behind it, so the artifact carries every byte the browser will ask for.
114
+ const islands = await buildIslands(options.root);
115
+ await writeIslands(islands, options.out);
53
116
 
54
117
  for (const entry of routeEntries()) {
55
118
  if (!isPrerenderable(entry)) {
56
119
  skipped.push(entry.path);
120
+ if (!declaresBudget(entry)) continue;
121
+ // Non-fatal, and that is deliberate: an ssr page's `load` may want a request, a session or a
122
+ // database this build does not have, and a `x build --target static` that started failing on
123
+ // routes it never used to touch would be a worse regression than the gap it closes. A route
124
+ // that will not render here is reported, gets no stats entry, and stays `X_BUDGET_UNMEASURED`.
125
+ try {
126
+ const html = await routeDocument(
127
+ entry,
128
+ { url: new URL(entry.path, origin).href, params: {} },
129
+ { resolveIsland: (file: string) => islands.resolverFor(file) },
130
+ );
131
+ const measured = await measureDocumentJs(html, options.out);
132
+ const chain = heaviestSource(islands, measured.entries);
133
+ routes.push({
134
+ path: entry.path,
135
+ jsBytes: measured.jsBytes,
136
+ ...(chain === undefined ? {} : { heaviestChain: chain }),
137
+ });
138
+ } catch (error) {
139
+ // `renderThrowable`, never `String(error)`: this is a caught unknown, and a hostile
140
+ // `toString` here would take the whole build down instead of one route's measurement.
141
+ unmeasured.push({ path: entry.path, reason: renderThrowable(error) });
142
+ }
57
143
  continue;
58
144
  }
59
145
  const artifacts = await renderStatic(
60
146
  entry,
61
- ({ path, params }) => routeDocument(entry, { url: new URL(path, origin).href, params }),
147
+ ({ path, params }) =>
148
+ routeDocument(
149
+ entry,
150
+ { url: new URL(path, origin).href, params },
151
+ { resolveIsland: (file: string) => islands.resolverFor(file) },
152
+ ),
62
153
  { buildId },
63
154
  );
64
155
  for (const artifact of artifacts) {
65
156
  const file = join(options.out, artifact.outputPath);
66
157
  const bytes = await Bun.write(file, artifact.html);
67
158
  pages.push({ path: artifact.path, file: artifact.outputPath, hash: artifact.hash, bytes });
159
+ // Measured from the document that was just written, so the `budgets` step compares a
160
+ // declared budget against bytes that exist on disk rather than against a graph's estimate.
161
+ const measured = await measureDocumentJs(artifact.html, options.out);
162
+ const chain = heaviestSource(islands, measured.entries);
163
+ routes.push({
164
+ path: artifact.path,
165
+ jsBytes: measured.jsBytes,
166
+ ...(chain === undefined ? {} : { heaviestChain: chain }),
167
+ });
68
168
  }
69
169
  }
70
- return { out: options.out, buildId, pages, skipped };
170
+ const stats = await writeBuildStats(options.root, { routes });
171
+ return {
172
+ out: options.out,
173
+ buildId,
174
+ pages,
175
+ skipped,
176
+ unmeasured,
177
+ stats,
178
+ islands: islands.chunks.map((chunk) => chunk.file),
179
+ };
71
180
  }
package/src/registry.ts CHANGED
@@ -5,7 +5,9 @@ import { buildCommand } from './cmd-build';
5
5
  import { dbCommand } from './cmd-db';
6
6
  import { deployCommand } from './cmd-deploy';
7
7
  import { devCommand } from './cmd-dev';
8
+ import { docsCommand } from './cmd-docs';
8
9
  import { doctorCommand } from './cmd-doctor';
10
+ import { envCommand } from './cmd-env';
9
11
  import { errorsCommand } from './cmd-errors';
10
12
  import { fixCommand } from './cmd-fix';
11
13
  import { generateCommand } from './cmd-generate';
@@ -19,6 +21,7 @@ import { plannedCommands } from './cmd-planned';
19
21
  import { policyCommand } from './cmd-policy';
20
22
  import { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
21
23
  import { routesCommand } from './cmd-routes';
24
+ import { secretsCommand } from './cmd-secrets';
22
25
  import { tasksCommand } from './cmd-tasks';
23
26
  import { testCommand } from './cmd-test';
24
27
  import { verifyCommand } from './cmd-verify';
@@ -26,8 +29,20 @@ import type { CliCommand } from './command';
26
29
  import type { CommandSpec } from './parse';
27
30
  import { loadVersion } from './version-loader';
28
31
 
29
- /** Single source of truth for the framework version — loaded from root package.json. */
30
- export const CLI_VERSION = loadVersion();
32
+ let cliVersionCache: string | undefined;
33
+
34
+ /**
35
+ * Single source of truth for the CLI's own version — loaded from its package.json, lazily.
36
+ * `index.ts` re-exports this module, so importing `@ultimat3/cli` for `runRole` alone — what a
37
+ * compiled `apps/web/server.ts` does — must not read a manifest a `--compile` binary does not
38
+ * carry. An eager `const` here reintroduced exactly the failure `frameworkVersion()` was made
39
+ * lazy to fix, one file over: it compiled clean and threw at import on the first boot that
40
+ * actually ran the artifact.
41
+ */
42
+ export function cliVersion(): string {
43
+ if (cliVersionCache === undefined) cliVersionCache = loadVersion();
44
+ return cliVersionCache;
45
+ }
31
46
 
32
47
  const CORE: readonly CliCommand[] = [
33
48
  newCommand,
@@ -40,6 +55,8 @@ const CORE: readonly CliCommand[] = [
40
55
  mcpCommand,
41
56
  doctorCommand,
42
57
  deployCommand,
58
+ envCommand,
59
+ secretsCommand,
43
60
  manifestCommand,
44
61
  routesCommand,
45
62
  actionsCommand,
@@ -50,6 +67,7 @@ const CORE: readonly CliCommand[] = [
50
67
  policyCommand,
51
68
  i18nCommand,
52
69
  errorsCommand,
70
+ docsCommand,
53
71
  fixCommand,
54
72
  ];
55
73
 
@@ -62,7 +80,7 @@ export const COMMANDS: readonly CliCommand[] = [
62
80
  ...CORE,
63
81
  ...plannedCommands(),
64
82
  createHelpCommand(() => SPECS),
65
- createVersionCommand(CLI_VERSION),
83
+ createVersionCommand(cliVersion),
66
84
  ];
67
85
 
68
86
  export const SPECS: readonly CommandSpec[] = COMMANDS.map((command) => command.spec);