@ultimat3/cli 24.0.0 → 25.1.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.
- package/CLAUDE.md +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/app-mcp.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
|
|
2
2
|
// `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
|
|
3
|
-
// `ai: { mcp: { expose: true
|
|
3
|
+
// `ai: { mcp: { expose: true } }` by DEFAULT, and nothing between the two served it:
|
|
4
4
|
// neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
|
|
5
5
|
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
6
|
// exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
|
|
7
|
-
// affiliates) — this module finds it, and both boots mount what it carries: endpoint
|
|
8
|
-
// `
|
|
7
|
+
// affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
|
|
8
|
+
// own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name.
|
|
9
9
|
|
|
10
10
|
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
@@ -13,7 +13,7 @@ import { existsSync } from 'node:fs';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { logger } from '@ultimat3/core';
|
|
15
15
|
import type { Route } from '@ultimat3/http';
|
|
16
|
-
import {
|
|
16
|
+
import { jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
17
17
|
import {
|
|
18
18
|
type AppMcp,
|
|
19
19
|
McpAppUnmountedError,
|
|
@@ -36,7 +36,7 @@ export const APP_MCP_ROUTE_NAME = 'mcp';
|
|
|
36
36
|
export interface AppMcpMount {
|
|
37
37
|
/** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
|
|
38
38
|
readonly routes: readonly Route[];
|
|
39
|
-
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default
|
|
39
|
+
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default endpoint. */
|
|
40
40
|
readonly path: string | null;
|
|
41
41
|
/** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
|
|
42
42
|
readonly paths: readonly string[];
|
|
@@ -48,17 +48,19 @@ export interface AppMcpMount {
|
|
|
48
48
|
readonly warning: McpAppUnmountedError | undefined;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
interface ExposeDeclaration {
|
|
52
|
-
readonly expose: boolean;
|
|
53
|
-
readonly path: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
51
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
57
52
|
typeof value === 'object' && value !== null;
|
|
58
53
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `defineAppMcp`'s own default — `mcp/src/transport-http.ts`, `input.path ?? '/mcp'` — named here
|
|
56
|
+
* ONLY in a warning about an endpoint that has no route and so no resolved path. It never decides
|
|
57
|
+
* where anything mounts: a mounted endpoint's path is `route.path`.
|
|
58
|
+
*/
|
|
59
|
+
const UNROUTED_MCP_PATH = '/mcp';
|
|
60
|
+
|
|
61
|
+
/** `config.ai.mcp.expose` off the one loader (`app-config-load.ts`); false with no config file. */
|
|
62
|
+
async function exposed(root: string): Promise<boolean> {
|
|
63
|
+
return (await loadAppConfig(root))?.ai.mcp.expose ?? false;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
@@ -94,21 +96,20 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
|
|
|
94
96
|
* does not have and answer 401 before the token was ever read.
|
|
95
97
|
*/
|
|
96
98
|
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
97
|
-
|
|
98
|
-
if (declared === undefined || !declared.expose) return NOTHING;
|
|
99
|
+
if (!(await exposed(root))) return NOTHING;
|
|
99
100
|
const files = await candidates(root);
|
|
100
101
|
const fallbackFile = 'apps/web/mcp.ts';
|
|
101
102
|
for (const file of files) {
|
|
102
103
|
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
103
104
|
const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
|
|
104
105
|
if (endpoints === undefined) continue;
|
|
105
|
-
return mountEndpoints(endpoints,
|
|
106
|
+
return mountEndpoints(endpoints, file);
|
|
106
107
|
}
|
|
107
108
|
return {
|
|
108
109
|
...NOTHING,
|
|
109
110
|
warning: new McpAppUnmountedError({
|
|
110
111
|
reason: 'missing',
|
|
111
|
-
path:
|
|
112
|
+
path: UNROUTED_MCP_PATH,
|
|
112
113
|
file: files[0] ?? fallbackFile,
|
|
113
114
|
}),
|
|
114
115
|
};
|
|
@@ -119,11 +120,7 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
|
119
120
|
* route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
|
|
120
121
|
* the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
|
|
121
122
|
*/
|
|
122
|
-
function mountEndpoints(
|
|
123
|
-
endpoints: readonly AppMcp[],
|
|
124
|
-
defaultPath: string,
|
|
125
|
-
file: string,
|
|
126
|
-
): AppMcpMount {
|
|
123
|
+
function mountEndpoints(endpoints: readonly AppMcp[], file: string): AppMcpMount {
|
|
127
124
|
const routes: Route[] = [];
|
|
128
125
|
const paths: string[] = [];
|
|
129
126
|
const claimed = new Map<string, number>();
|
|
@@ -133,13 +130,15 @@ function mountEndpoints(
|
|
|
133
130
|
if (route === undefined) {
|
|
134
131
|
warning ??= new McpAppUnmountedError({
|
|
135
132
|
reason: 'no-route',
|
|
136
|
-
path:
|
|
133
|
+
path: UNROUTED_MCP_PATH,
|
|
137
134
|
file,
|
|
138
135
|
...(index === 0 ? {} : { endpoint: index }),
|
|
139
136
|
});
|
|
140
137
|
return;
|
|
141
138
|
}
|
|
142
|
-
|
|
139
|
+
// The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
|
|
140
|
+
// over. A mount path from anywhere else is a resource advertised where nothing answers.
|
|
141
|
+
const path = route.path;
|
|
143
142
|
for (const mounted of endpointRoutes(route, path, index)) {
|
|
144
143
|
const key = `${mounted.method} ${mounted.path}`;
|
|
145
144
|
const first = claimed.get(key);
|
|
@@ -156,8 +155,7 @@ function mountEndpoints(
|
|
|
156
155
|
}
|
|
157
156
|
paths.push(path);
|
|
158
157
|
});
|
|
159
|
-
|
|
160
|
-
return { routes, path, paths, warning };
|
|
158
|
+
return { routes, path: endpoints[0]?.route?.path ?? null, paths, warning };
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
type Descriptor = NonNullable<AppMcp['route']>;
|
|
@@ -179,7 +177,7 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
|
|
|
179
177
|
method: 'GET',
|
|
180
178
|
path: metadataPath,
|
|
181
179
|
handler: (_request, ctx) => {
|
|
182
|
-
const response =
|
|
180
|
+
const response = jsonResponse(resource.document(selfOrigin(ctx.url, ctx.https)));
|
|
183
181
|
// A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
|
|
184
182
|
response.headers.set('access-control-allow-origin', '*');
|
|
185
183
|
response.headers.set('cache-control', 'public, max-age=3600');
|
package/src/app-name.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `x new <name>`'s last refusal before a byte lands: the slug must be a name core's `defineConfig`
|
|
2
|
+
// accepts, because it is written verbatim as `app.config.ts`'s `name:` and spelled into every
|
|
3
|
+
// identifier the scaffold derives (`<app>Seed`). The rule is asked of core, never restated here.
|
|
4
|
+
|
|
5
|
+
import { defineConfig, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why core's own config validator refuses `name`, in core's words, or `undefined` when it accepts
|
|
9
|
+
* it — asked by building the one config that name would scaffold, every other field defaulted. A
|
|
10
|
+
* copy of core's pattern here would be a second answer to "what is an app name", and the day one
|
|
11
|
+
* of them moved `x new` would again write an app whose `app.config.ts` throws at its own import.
|
|
12
|
+
*/
|
|
13
|
+
export function appNameIssue(name: string): string | undefined {
|
|
14
|
+
try {
|
|
15
|
+
defineConfig({ name });
|
|
16
|
+
return undefined;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return isUltimateError(error) ? error.cause : renderThrowable(error);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The bound the suggestion trims an over-long slug to — re-proved by `appNameIssue`, not trusted. */
|
|
23
|
+
const MAX_LENGTH = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A slug core accepts, as close to `slug` as one edit gets: a leading digit gains an `app-` prefix
|
|
27
|
+
* (an identifier cannot start with one either), a one-letter name an `-app` suffix, and an
|
|
28
|
+
* over-long one is cut at the bound. `undefined` when none of those land on a valid name.
|
|
29
|
+
*/
|
|
30
|
+
export function suggestAppName(slug: string): string | undefined {
|
|
31
|
+
let candidate = /^[0-9]/.test(slug) ? `app-${slug}` : slug;
|
|
32
|
+
if (candidate.length < 2) candidate = `${candidate}-app`;
|
|
33
|
+
candidate = candidate.slice(0, MAX_LENGTH).replace(/-+$/, '');
|
|
34
|
+
return appNameIssue(candidate) === undefined ? candidate : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `x new 9lives`, `x new a`: a slug core's `defineConfig` would refuse at the app's first boot. */
|
|
38
|
+
export class AppNameInvalidError extends UltimateError {
|
|
39
|
+
constructor(input: {
|
|
40
|
+
name: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
issue: string;
|
|
43
|
+
invocation: string;
|
|
44
|
+
flags: readonly string[];
|
|
45
|
+
}) {
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_CLI_BAD_FLAG',
|
|
48
|
+
cause: `"${input.name}" scaffolds the app name "${input.slug}", which app.config.ts's defineConfig refuses: ${input.issue}`,
|
|
49
|
+
fix: [input.invocation, suggestAppName(input.slug) ?? '<name>', ...input.flags].join(' '),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -3,13 +3,11 @@
|
|
|
3
3
|
// delete` for every mounted entity, so an app rule naming one passed in the web process and threw
|
|
4
4
|
// X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
|
|
5
5
|
|
|
6
|
-
// why: Bun ships no path API, and `relative` renders a frame's absolute path against the app root.
|
|
7
|
-
import { relative } from 'node:path';
|
|
8
6
|
import { listActions } from '@ultimat3/action';
|
|
9
7
|
import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
|
|
10
8
|
import { listQueries } from '@ultimat3/query';
|
|
11
9
|
import { routeEntries } from '@ultimat3/render';
|
|
12
|
-
import { frameFile, siteFile } from './app-permissions-site';
|
|
10
|
+
import { frameFile, rootRelative, siteFile } from './app-permissions-site';
|
|
13
11
|
import type { Finding } from './output';
|
|
14
12
|
|
|
15
13
|
/** One permission one app declaration requires. */
|
|
@@ -72,11 +70,7 @@ export function borrowedPermissions(
|
|
|
72
70
|
if (files.length === 0 || files.some((file) => file === undefined)) continue;
|
|
73
71
|
if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
|
|
74
72
|
const from = [
|
|
75
|
-
...new Set(
|
|
76
|
-
files.flatMap((file) =>
|
|
77
|
-
file === undefined ? [] : [relative(root, file).replaceAll('\\', '/')],
|
|
78
|
-
),
|
|
79
|
-
),
|
|
73
|
+
...new Set(files.flatMap((file) => (file === undefined ? [] : [rootRelative(root, file)]))),
|
|
80
74
|
].sort();
|
|
81
75
|
found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
|
|
82
76
|
}
|
|
@@ -2,15 +2,39 @@
|
|
|
2
2
|
// `defineRoles()` or `definePermissions()` call — reduced to the file it names, absolute or
|
|
3
3
|
// app-root-relative. Both `policy` step questions key their findings by it.
|
|
4
4
|
|
|
5
|
-
// why: Bun ships no path API, and `relative` is what turns a
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// why: Bun ships no path API, and `posix.relative` is what turns a frame's absolute path into the
|
|
6
|
+
// app-root-relative one every finding is keyed by — on POSIX-normalised input, so a Windows frame
|
|
7
|
+
// and root compare the same way on every host.
|
|
8
|
+
import { posix } from 'node:path';
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The location at the END of a frame — `at fn (<path>:<line>:<col>)` or `at <path>:<line>:<col>` —
|
|
12
|
+
* read lazily from the first place a path can start, so a `(` INSIDE the path (`/srv/shop (copy)/`)
|
|
13
|
+
* is part of it, and a drive letter (`C:\…`) is a path start too. Anchored at the end because a
|
|
14
|
+
* frame's location is always its last token, and the function name before it is not a path.
|
|
15
|
+
*/
|
|
16
|
+
const FRAME_LOCATION = /(?:^|[\s(])((?:[A-Za-z]:)?[\\/].*?\.[cm]?tsx?)(?::\d+){0,2}\)?\s*$/;
|
|
17
|
+
|
|
18
|
+
/** The absolute source path a frame names, exactly as written, or `undefined`. */
|
|
10
19
|
export function frameFile(site: string): string | undefined {
|
|
11
|
-
return
|
|
20
|
+
return FRAME_LOCATION.exec(site)?.[1];
|
|
12
21
|
}
|
|
13
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `\` → `/`, and a drive letter rooted like a POSIX path with its case folded (`C:\a` → `/c:/a`):
|
|
25
|
+
* `@ultimat3/policy`'s `declaration-site.ts` compares frames `/`-normalised for the same reason —
|
|
26
|
+
* on Windows neither a frame nor a root holds a `/`, and a shell spells the drive either way.
|
|
27
|
+
*/
|
|
28
|
+
const comparable = (path: string): string => {
|
|
29
|
+
const slashed = path.replaceAll('\\', '/');
|
|
30
|
+
const drive = /^([A-Za-z]):\//.exec(slashed);
|
|
31
|
+
return drive === null ? slashed : `/${(drive[1] ?? '').toLowerCase()}:${slashed.slice(2)}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** `file` relative to `root`, `/`-separated, whichever OS printed either of them. */
|
|
35
|
+
export const rootRelative = (root: string, file: string): string =>
|
|
36
|
+
posix.relative(comparable(root), comparable(file));
|
|
37
|
+
|
|
14
38
|
/**
|
|
15
39
|
* The frame's file relative to the app root, or `undefined` outside it: a locator nobody can act on
|
|
16
40
|
* is worse than none. The frame is the only place the declaring module's name exists by the time
|
|
@@ -19,6 +43,6 @@ export function frameFile(site: string): string | undefined {
|
|
|
19
43
|
export function siteFile(root: string, site: string): string | undefined {
|
|
20
44
|
const absolute = frameFile(site);
|
|
21
45
|
if (absolute === undefined) return undefined;
|
|
22
|
-
const rel =
|
|
23
|
-
return rel.startsWith('..') || rel.length === 0 ? undefined : rel;
|
|
46
|
+
const rel = rootRelative(root, absolute);
|
|
47
|
+
return rel.startsWith('..') || rel.length === 0 || rel.startsWith('/') ? undefined : rel;
|
|
24
48
|
}
|
package/src/app-root.ts
CHANGED
|
@@ -20,10 +20,14 @@ export const MANIFEST_FILE = 'x.manifest.json';
|
|
|
20
20
|
* the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
|
|
21
21
|
* the same release and is emitted now, so the floor may never fall below that patch.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4
|
|
26
|
-
*
|
|
23
|
+
* A 1.4 patch rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI
|
|
24
|
+
* pins one exact patch, both images build on that patch's `oven/bun:1.4-*` digest, and the
|
|
25
|
+
* per-worker database rests on `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4 and on nothing older.
|
|
26
|
+
* It moved 1.4.0 → 1.4.2 with CI's pin (plan 2026/10/04/101, sweep 3): from then on 1.4.0 was a
|
|
27
|
+
* runtime nothing here ran, and the bundler defects fixed in 1.4.1 (oven-sh/bun#40578, #40650,
|
|
28
|
+
* #40657) change what `x build` emits. `scaffold-repo.ts` and the scaffold's CI read this constant,
|
|
29
|
+
* so a new app's `engines.bun` and its CI runtime move with it. `scripts/bun-pin.test.ts` holds it
|
|
30
|
+
* to the exact patch CI runs.
|
|
27
31
|
*
|
|
28
32
|
* **Lowering it to 1.3.14 was tried on 2026-08-27 and refused**, and the argument for trying was
|
|
29
33
|
* sound — `--isolate` and `--parallel` are 1.3.13 features, no package here calls a 1.4-only API
|
|
@@ -34,7 +38,7 @@ export const MANIFEST_FILE = 'x.manifest.json';
|
|
|
34
38
|
* declared supported would hang on graceful shutdown the moment its database went away. The full
|
|
35
39
|
* measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
|
|
36
40
|
*/
|
|
37
|
-
export const REQUIRED_BUN = '1.4.
|
|
41
|
+
export const REQUIRED_BUN = '1.4.2';
|
|
38
42
|
|
|
39
43
|
export interface AppRoot {
|
|
40
44
|
readonly dir: string;
|
package/src/bin.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
6
|
import { resolveLocalCli } from './local-cli';
|
|
7
|
+
import { handOff } from './local-cli-handoff';
|
|
7
8
|
import { cwdFromArgv } from './root-env';
|
|
8
9
|
// The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
|
|
9
10
|
// one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
|
|
@@ -24,13 +25,8 @@ const local = resolveLocalCli({
|
|
|
24
25
|
});
|
|
25
26
|
if (local !== undefined) {
|
|
26
27
|
writeErrorLine(`x: using the app's own @ultimat3/cli at ${local}`);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
stdout: 'inherit',
|
|
30
|
-
stderr: 'inherit',
|
|
31
|
-
env: Bun.env,
|
|
32
|
-
});
|
|
33
|
-
process.exit(await child.exited);
|
|
28
|
+
// Stop signals are forwarded and the child's code is ours: see local-cli-handoff.ts.
|
|
29
|
+
process.exit(await handOff([process.execPath, local, ...Bun.argv.slice(2)], Bun.env));
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
// `x dev` supervises a child that serves (`dev-supervisor.ts`): a save that reaches a module which
|
package/src/browser-launcher.ts
CHANGED
|
@@ -39,7 +39,8 @@ export class ShotChromeMissingError extends UltimateError {
|
|
|
39
39
|
// One literal, so `fix-scan.ts` can read it. A path is the one repair that works whatever the
|
|
40
40
|
// browser is and wherever the distribution put it; `--cdp-url` is the answer for a box that
|
|
41
41
|
// will never have one, and it is a flag `x shot` already ships.
|
|
42
|
-
|
|
42
|
+
// The `#` names the macOS and Windows spellings: one literal for every platform.
|
|
43
|
+
fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome, Chromium or Edge binary — macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows PowerShell: $env:CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" (Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"); a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
|
|
43
44
|
meta: { tried: [...CHROME_CANDIDATES], vars: [...BROWSER_PATH_VARS] },
|
|
44
45
|
});
|
|
45
46
|
}
|
|
@@ -67,7 +68,8 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
|
67
68
|
/**
|
|
68
69
|
* The path a run will launch, or `undefined` when this machine has no browser to launch.
|
|
69
70
|
*
|
|
70
|
-
* The last step is a PROBE — the
|
|
71
|
+
* The last step is a PROBE — the paths `cdp-launch.ts` tries for the e2e driver on THIS platform
|
|
72
|
+
* (`/usr/bin` on Linux, the app bundles on macOS, Chrome and Edge on Windows), imported
|
|
71
73
|
* rather than restated, because two lists of Chrome locations that must agree is the drift axiom 2
|
|
72
74
|
* refuses — so a box with no browser is refused before a dev-server boot, not one launch later.
|
|
73
75
|
*
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What a route's measured JavaScript was made of, file by file: the clause and the `--json` facts
|
|
2
|
+
// `X_BUDGET_EXCEEDED` carries, so a reader can tell a heavier chunk from a byte counted twice
|
|
3
|
+
// without re-running the build. Split from `budgets.ts`, which measures and compares.
|
|
4
|
+
|
|
5
|
+
import type { JsonValue } from './output';
|
|
6
|
+
|
|
7
|
+
/** One app file a document made the browser fetch, and its bytes on disk. */
|
|
8
|
+
export interface ChargedFile {
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly bytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Named in the cause; the rest are summed. A page carries one or two files in practice, and a
|
|
15
|
+
* sentence listing forty is one nobody reads — `meta.charged` still holds every one of them.
|
|
16
|
+
*/
|
|
17
|
+
const NAMED_FILES = 5;
|
|
18
|
+
|
|
19
|
+
/** Heaviest first, then by URL, so the clause and the stats file are reproducible. */
|
|
20
|
+
export function heaviestFirst(files: readonly ChargedFile[]): readonly ChargedFile[] {
|
|
21
|
+
return [...files].sort((a, b) =>
|
|
22
|
+
b.bytes !== a.bytes ? b.bytes - a.bytes : a.url < b.url ? -1 : a.url > b.url ? 1 : 0,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `; charged: /islands/post-form-04f75f18.js 81306 B, inline scripts 855 B` — exact bytes, never
|
|
28
|
+
* `formatBytes`: a 22 kB disagreement between two platforms is the question this answers, and a
|
|
29
|
+
* rounded figure hides the byte a duplicate shows up as. Empty for a row an older build wrote.
|
|
30
|
+
*/
|
|
31
|
+
export function chargedClause(
|
|
32
|
+
charged: readonly ChargedFile[] | undefined,
|
|
33
|
+
inlineBytes: number | undefined,
|
|
34
|
+
): string {
|
|
35
|
+
const files = heaviestFirst(charged ?? []);
|
|
36
|
+
const parts = files.slice(0, NAMED_FILES).map((one) => `${one.url} ${String(one.bytes)} B`);
|
|
37
|
+
const rest = files.slice(NAMED_FILES);
|
|
38
|
+
if (rest.length > 0) {
|
|
39
|
+
const bytes = rest.reduce((sum, one) => sum + one.bytes, 0);
|
|
40
|
+
parts.push(`${String(rest.length)} more files ${String(bytes)} B`);
|
|
41
|
+
}
|
|
42
|
+
if (inlineBytes !== undefined && inlineBytes > 0) {
|
|
43
|
+
parts.push(`inline scripts ${String(inlineBytes)} B`);
|
|
44
|
+
}
|
|
45
|
+
return parts.length === 0 ? '' : `; charged: ${parts.join(', ')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The same facts as data, or `undefined` when the row predates them — absent is not empty. */
|
|
49
|
+
export function chargedMeta(
|
|
50
|
+
jsBytes: number,
|
|
51
|
+
charged: readonly ChargedFile[] | undefined,
|
|
52
|
+
inlineBytes: number | undefined,
|
|
53
|
+
): { readonly [key: string]: JsonValue } | undefined {
|
|
54
|
+
if (charged === undefined && inlineBytes === undefined) return undefined;
|
|
55
|
+
return {
|
|
56
|
+
jsBytes,
|
|
57
|
+
charged: heaviestFirst(charged ?? []).map((one) => ({ url: one.url, bytes: one.bytes })),
|
|
58
|
+
inlineJsBytes: inlineBytes ?? 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// How the budget measurer READS an emitted document: which tags are code, which attribute names an
|
|
2
|
+
// island entry, which path a URL is fetched at, and which files a module makes the browser load.
|
|
3
|
+
// Split from `budgets.ts`, which decides what of that is charged and compares it to the budget.
|
|
4
|
+
|
|
5
|
+
import { SPECULATION_RULES_TYPE } from '@ultimat3/render';
|
|
6
|
+
|
|
7
|
+
export const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
|
|
8
|
+
export const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
|
|
9
|
+
const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
|
|
13
|
+
* `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
|
|
14
|
+
* `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
|
|
15
|
+
* Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
|
|
16
|
+
* and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
|
|
17
|
+
*/
|
|
18
|
+
export const carriesJson = (attrs: string): boolean => {
|
|
19
|
+
// Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
|
|
20
|
+
// `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
|
|
21
|
+
// test alone charged an SEO structured-data block as executable JavaScript all over again.
|
|
22
|
+
const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
|
|
23
|
+
const declared = type.trim().toLowerCase();
|
|
24
|
+
// Speculation rules are JSON under a type that does not say so: read by the browser's
|
|
25
|
+
// prefetcher, never executed — the one script a 0kb page may carry for free.
|
|
26
|
+
return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
|
|
31
|
+
* as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
|
|
32
|
+
* never for the code that runtime boots. The entry attribute is that module URL, so it is read as
|
|
33
|
+
* exactly what it is: a file the browser will execute.
|
|
34
|
+
*/
|
|
35
|
+
export const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
|
|
39
|
+
* before islands were split, so charging it keeps a route's number what it was; and it is fetched
|
|
40
|
+
* the moment the island needs it, which is before the page does what it was loaded for.
|
|
41
|
+
*/
|
|
42
|
+
const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
|
|
43
|
+
|
|
44
|
+
/** The origin every artifact path is resolved against — a name that can never be a real host. */
|
|
45
|
+
const ARTIFACT_ORIGIN = 'https://artifact.invalid';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The path a browser fetches for a root-relative `url`, or `undefined` for one that is not this
|
|
49
|
+
* artifact's. Resolved with the WHATWG parser, the browser's own: it reads `\` as `/`, folds `.`
|
|
50
|
+
* and `..`, drops a query, and makes `//host/a.js` another origin. The dedupe below is keyed by
|
|
51
|
+
* this, because it is the FETCH that is counted once, and a raw string kept two spellings of one
|
|
52
|
+
* file apart — which `join` on Windows then turned back into one real path, charged twice.
|
|
53
|
+
*/
|
|
54
|
+
export function artifactPath(url: string): string | undefined {
|
|
55
|
+
if (!url.startsWith('/')) return undefined;
|
|
56
|
+
const resolved = URL.parse(url, ARTIFACT_ORIGIN);
|
|
57
|
+
return resolved?.origin === ARTIFACT_ORIGIN ? resolved.pathname : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
|
|
61
|
+
export function loadedBy(url: string, code: string): readonly string[] {
|
|
62
|
+
let imports: readonly { readonly path: string; readonly kind: string }[];
|
|
63
|
+
try {
|
|
64
|
+
imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
|
|
65
|
+
} catch {
|
|
66
|
+
// A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
|
|
67
|
+
// are already charged.
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const base = new URL(url, ARTIFACT_ORIGIN);
|
|
71
|
+
return imports
|
|
72
|
+
.filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
|
|
73
|
+
.map((one) => new URL(one.path, base))
|
|
74
|
+
.filter((target) => target.origin === base.origin)
|
|
75
|
+
.map((target) => target.pathname);
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Single responsibility: the finding for a budgeted route a build RAN and could not weigh, read off
|
|
2
|
+
// the static report's `unmeasured` entry — under the failure's own code when that code is the
|
|
3
|
+
// instruction, as an actor edit when the measurement actor was refused, the report otherwise.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
|
+
import type { Finding } from './output';
|
|
7
|
+
import type { UnmeasuredRoute } from './static-report';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The ONE code a failed measurement is reported under by its own name rather than as
|
|
11
|
+
* `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
|
|
12
|
+
* its fix is an edit to the page, so the step's own "run x build and read the list" would put a
|
|
13
|
+
* second command between the author and a sentence the build had already composed. Every other
|
|
14
|
+
* render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
|
|
15
|
+
* a reason to read the report, not an instruction.
|
|
16
|
+
*
|
|
17
|
+
* ONE code and not "any coded error", deliberately. `X_NO_CONTEXT` and `X_DB_UNAVAILABLE` from a
|
|
18
|
+
* measurement render are facts about the BUILD's environment, and reporting them under their own
|
|
19
|
+
* codes would tell the author to fix a database the gate never had. The list grows by a decision,
|
|
20
|
+
* per code, here.
|
|
21
|
+
*/
|
|
22
|
+
const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
|
|
23
|
+
'X_ISLAND_PROPS_INVALID',
|
|
24
|
+
// A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
|
|
25
|
+
'X_BUDGET_PARAMS_UNDECLARED',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refusals of WHO the render ran as. The actor is the app's own declaration (core's
|
|
30
|
+
* `defineMeasurementActor`, in `app.config.ts`), so these ARE an instruction — but the refusal's
|
|
31
|
+
* own `fix:` was written for a request ("sign in", "mint the actor with its tenant at the request
|
|
32
|
+
* boundary") and names no build-time edit. The default actor is a service actor holding `*` with
|
|
33
|
+
* no org and no roles: a page whose policy names a role, or whose `load` reads a tenant-scoped
|
|
34
|
+
* entity, is refused under it (#675).
|
|
35
|
+
*/
|
|
36
|
+
const ACTOR_REFUSALS: ReadonlySet<string> = new Set([
|
|
37
|
+
'X_FORBIDDEN',
|
|
38
|
+
'X_UNAUTHENTICATED',
|
|
39
|
+
'X_TENANCY_ACTOR_ORG_REQUIRED',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function ownCodeFinding(url: string, entry: UnmeasuredRoute): Finding {
|
|
43
|
+
return {
|
|
44
|
+
code: entry.code ?? 'X_BUDGET_UNMEASURED',
|
|
45
|
+
cause: entry.cause ?? entry.reason,
|
|
46
|
+
fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
|
|
47
|
+
docs: ERROR_DOCS_URL,
|
|
48
|
+
at: url,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `CODE: cause` when the failure had a code, its rendered reason otherwise. */
|
|
53
|
+
const refusalOf = (entry: UnmeasuredRoute): string =>
|
|
54
|
+
entry.code === undefined ? entry.reason : `${entry.code}: ${entry.cause ?? entry.reason}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A build ran and has no row for `url`. `entry` is the report's account of why, when it has one:
|
|
58
|
+
* an instruction under its own code, an actor edit, or — for a failure that is a fact about the
|
|
59
|
+
* build's environment — the report, with the refusal already in the cause.
|
|
60
|
+
*/
|
|
61
|
+
export function builtUnmeasuredFinding(
|
|
62
|
+
url: string,
|
|
63
|
+
declared: string,
|
|
64
|
+
statsFile: string,
|
|
65
|
+
entry: UnmeasuredRoute | undefined,
|
|
66
|
+
): Finding {
|
|
67
|
+
if (entry?.code !== undefined && REPORTED_BY_OWN_CODE.has(entry.code)) {
|
|
68
|
+
return ownCodeFinding(url, entry);
|
|
69
|
+
}
|
|
70
|
+
const base = `${url} declares a ${declared} budget and ${statsFile} has no row for it, so the build ran and could not weigh it`;
|
|
71
|
+
if (entry?.code !== undefined && ACTOR_REFUSALS.has(entry.code)) {
|
|
72
|
+
const actor = entry.actor ?? 'the measurement actor';
|
|
73
|
+
return {
|
|
74
|
+
code: 'X_BUDGET_UNMEASURED',
|
|
75
|
+
cause: `${base}: rendered as ${actor}, it was refused ${refusalOf(entry)}`,
|
|
76
|
+
// The paste: one declaration in the one file every build imports.
|
|
77
|
+
fix: "defineMeasurementActor(() => userActor({ id: 'measure', orgId: '<an org your dev seed creates>', roles: ['<the role this page requires>'] })) # in app.config.ts",
|
|
78
|
+
docs: ERROR_DOCS_URL,
|
|
79
|
+
at: url,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
code: 'X_BUDGET_UNMEASURED',
|
|
84
|
+
cause: entry === undefined ? base : `${base}: ${refusalOf(entry)}`,
|
|
85
|
+
// When a build already ran, the report is where the rest of the answer is — it names every
|
|
86
|
+
// route it could not weigh, and why.
|
|
87
|
+
fix: `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`,
|
|
88
|
+
docs: ERROR_DOCS_URL,
|
|
89
|
+
at: url,
|
|
90
|
+
};
|
|
91
|
+
}
|