@ultimat3/cli 29.3.2 → 30.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.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What a reader is owed beyond a finding as it was thrown: a fix that runs where `x` is not on
|
|
2
|
+
// PATH, and one line for one cause. Render layer only, so every throw site keeps spelling `x …`,
|
|
3
|
+
// which is what `fix-command.ts` reads to prove the command exists.
|
|
4
|
+
|
|
5
|
+
import type { Finding, JsonValue } from './output';
|
|
6
|
+
|
|
7
|
+
const SEGMENT_END = /^(?:&&|\|\||;)/;
|
|
8
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s'"]*)\s+/;
|
|
9
|
+
|
|
10
|
+
/** Where the command word of a segment starts, past its `A=b` prefix. */
|
|
11
|
+
const commandWordAt = (fix: string, from: number): number => {
|
|
12
|
+
let at = from;
|
|
13
|
+
for (;;) {
|
|
14
|
+
while (fix[at] === ' ') at += 1;
|
|
15
|
+
const assignment = ENV_ASSIGNMENT.exec(fix.slice(at));
|
|
16
|
+
if (assignment === null) return at;
|
|
17
|
+
at += assignment[0].length;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isBareX = (fix: string, at: number): boolean =>
|
|
22
|
+
fix[at] === 'x' && (at + 1 === fix.length || fix[at + 1] === ' ');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A leading `x ` becomes `bunx x `, and after `&&`, `||` and `;`. Quoted text is an argument and a
|
|
26
|
+
* `#` tail is never run, so both are copied as they are. `bunx x` itself does not match, so a report
|
|
27
|
+
* read back by `x verify merge` and rendered again does not grow.
|
|
28
|
+
*/
|
|
29
|
+
export function runnableFix(fix: string): string {
|
|
30
|
+
let out = '';
|
|
31
|
+
let quote: string | undefined;
|
|
32
|
+
let at = 0;
|
|
33
|
+
let segmentStart = true;
|
|
34
|
+
while (at < fix.length) {
|
|
35
|
+
if (segmentStart) {
|
|
36
|
+
const word = commandWordAt(fix, at);
|
|
37
|
+
out += fix.slice(at, word);
|
|
38
|
+
at = word;
|
|
39
|
+
if (isBareX(fix, at)) out += 'bunx ';
|
|
40
|
+
segmentStart = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const char = fix[at] ?? '';
|
|
44
|
+
if (quote !== undefined) {
|
|
45
|
+
if (char === quote) quote = undefined;
|
|
46
|
+
} else if (char === "'" || char === '"') {
|
|
47
|
+
quote = char;
|
|
48
|
+
} else if (char === '#' && (at === 0 || fix[at - 1] === ' ')) {
|
|
49
|
+
return out + fix.slice(at);
|
|
50
|
+
} else {
|
|
51
|
+
const end = SEGMENT_END.exec(fix.slice(at));
|
|
52
|
+
if (end !== null) {
|
|
53
|
+
out += end[0];
|
|
54
|
+
at += end[0].length;
|
|
55
|
+
segmentStart = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out += char;
|
|
60
|
+
at += 1;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const keyOf = (finding: Finding): string =>
|
|
66
|
+
[finding.code, finding.cause, finding.fix].join('\u0000');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One finding per code + cause + fix, with how many reported it and where. A broken `$view` throws
|
|
70
|
+
* from every module importing its entity: 3 findings on one load, 29 on a larger app, one repair.
|
|
71
|
+
* The fix is in the key because a rule whose cause is generic names the file in its fix, and those
|
|
72
|
+
* are separate repairs. `reportedAt` keeps what `at` can no longer say.
|
|
73
|
+
*/
|
|
74
|
+
export function collapseFindings(findings: readonly Finding[]): readonly Finding[] {
|
|
75
|
+
const groups = new Map<string, Finding[]>();
|
|
76
|
+
for (const finding of findings) {
|
|
77
|
+
const key = keyOf(finding);
|
|
78
|
+
const group = groups.get(key);
|
|
79
|
+
if (group === undefined) groups.set(key, [finding]);
|
|
80
|
+
else group.push(finding);
|
|
81
|
+
}
|
|
82
|
+
return [...groups.values()].map((group) => {
|
|
83
|
+
const first = group[0] as Finding;
|
|
84
|
+
if (group.length === 1) return first;
|
|
85
|
+
const reportedAt = group.flatMap((one) => (one.at === undefined ? [] : [one.at]));
|
|
86
|
+
const meta: { [key: string]: JsonValue } = { ...first.meta, count: group.length };
|
|
87
|
+
if (reportedAt.length > 0) meta['reportedAt'] = reportedAt;
|
|
88
|
+
return { ...first, meta };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How many sites the human line names. `--json` carries all of them in `meta.reportedAt`. */
|
|
93
|
+
const SITES_SHOWN = 3;
|
|
94
|
+
|
|
95
|
+
/** The human half of a count: `meta` is never the only home of a fact. */
|
|
96
|
+
export function causeWithCount(finding: Finding): string {
|
|
97
|
+
const count = finding.meta?.['count'];
|
|
98
|
+
if (typeof count !== 'number') return finding.cause;
|
|
99
|
+
const sites = finding.meta?.['reportedAt'];
|
|
100
|
+
const named = Array.isArray(sites) ? sites.slice(0, SITES_SHOWN).join(', ') : '';
|
|
101
|
+
return `${finding.cause} (reported ${String(count)} times${named === '' ? '' : `: ${named}`})`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ReaderOptions {
|
|
105
|
+
/** `x` is not this reader's command: its fixes name `bunx x`. `dispatch` decides. */
|
|
106
|
+
readonly bunxRunner?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A finding read out of a CI log is fenced text and is never turned into an instruction. */
|
|
110
|
+
const withRunnableFix = (finding: Finding): Finding => {
|
|
111
|
+
if (finding.source === 'ci-log') return finding;
|
|
112
|
+
const fix = runnableFix(finding.fix);
|
|
113
|
+
return fix === finding.fix ? finding : { ...finding, fix };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The list a renderer prints. */
|
|
117
|
+
export const findingsForReader = (
|
|
118
|
+
findings: readonly Finding[],
|
|
119
|
+
options: ReaderOptions,
|
|
120
|
+
): readonly Finding[] => {
|
|
121
|
+
const collapsed = collapseFindings(findings);
|
|
122
|
+
return options.bunxRunner === true ? collapsed.map(withRunnableFix) : collapsed;
|
|
123
|
+
};
|
package/src/output.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// output (axiom 4). The human renderer owns the canonical 3-line error format.
|
|
4
4
|
|
|
5
5
|
import { ERROR_DOCS_URL, renderThrowable, singleLine, stringField } from '@ultimat3/core';
|
|
6
|
+
import { unresolvedImportFinding } from './app-load-unresolved';
|
|
6
7
|
import { msg } from './messages';
|
|
8
|
+
import { causeWithCount, findingsForReader, type ReaderOptions } from './output-findings';
|
|
7
9
|
import type { TestCounts } from './test-counts';
|
|
8
10
|
|
|
9
11
|
/** One shard's share of one step's corpus — what `x verify merge` checks the parts against. */
|
|
@@ -164,6 +166,9 @@ export function findingFrom(value: unknown): Finding {
|
|
|
164
166
|
const docs = stringField(value, 'docs');
|
|
165
167
|
return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
166
168
|
}
|
|
169
|
+
// An import that will not resolve, on a path outside the app scan: `bun add`, not `x doctor`.
|
|
170
|
+
const unresolved = unresolvedImportFinding(value, process.cwd());
|
|
171
|
+
if (unresolved !== undefined) return unresolved;
|
|
167
172
|
// This is the LAST renderer between a thrown value and the terminal: a hostile `toString`, a
|
|
168
173
|
// throwing `message` getter or a trapped `instanceof` here loses the whole report, not one line
|
|
169
174
|
// of it. `renderThrowable` is total on all three.
|
|
@@ -263,13 +268,19 @@ const why = (step: StepResult): string => {
|
|
|
263
268
|
const showsOutput = (step: StepResult, verbose: boolean): boolean =>
|
|
264
269
|
step.output !== undefined && step.output.length > 0 && (verbose || !step.ok);
|
|
265
270
|
|
|
266
|
-
export function renderHuman(
|
|
271
|
+
export function renderHuman(
|
|
272
|
+
result: CommandResult,
|
|
273
|
+
verbose = false,
|
|
274
|
+
reader: ReaderOptions = {},
|
|
275
|
+
): string {
|
|
267
276
|
const out: string[] = [];
|
|
268
277
|
for (const step of result.steps ?? []) {
|
|
269
278
|
out.push(
|
|
270
279
|
` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
|
|
271
280
|
);
|
|
272
|
-
for (const finding of step.findings
|
|
281
|
+
for (const finding of findingsForReader(step.findings, reader)) {
|
|
282
|
+
out.push(renderFinding({ ...finding, cause: causeWithCount(finding) }, ' '));
|
|
283
|
+
}
|
|
273
284
|
// Escaped like every other line text reaches fd 1 through: a warning names a file path. A
|
|
274
285
|
// green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
|
|
275
286
|
// and a wall of advice on every run is read by nobody. `--json` always carries the list.
|
|
@@ -298,19 +309,25 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
298
309
|
// comment retitled the window and cleared the screen, and a newline in one entry printed a
|
|
299
310
|
// second line a reader — or the agent this command exists for — takes for the renderer's own.
|
|
300
311
|
for (const line of result.lines ?? []) out.push(singleLine(line));
|
|
301
|
-
for (const finding of result.findings ?? []
|
|
312
|
+
for (const finding of findingsForReader(result.findings ?? [], reader)) {
|
|
313
|
+
out.push(renderFinding({ ...finding, cause: causeWithCount(finding) }, ' '));
|
|
314
|
+
}
|
|
302
315
|
// The summary is foreign too: `cli.shot.threw` interpolates a page's own error message.
|
|
303
316
|
out.push(`${result.ok ? '✓' : '✗'} ${singleLine(result.summary)}`);
|
|
304
317
|
return out.join('\n');
|
|
305
318
|
}
|
|
306
319
|
|
|
307
|
-
export function renderJson(
|
|
320
|
+
export function renderJson(
|
|
321
|
+
result: CommandResult,
|
|
322
|
+
verbose = false,
|
|
323
|
+
reader: ReaderOptions = {},
|
|
324
|
+
): string {
|
|
308
325
|
const steps = (result.steps ?? []).map((step) => ({
|
|
309
326
|
name: step.name,
|
|
310
327
|
ok: step.ok,
|
|
311
328
|
durationMs: step.durationMs,
|
|
312
329
|
skipped: step.skipped === true,
|
|
313
|
-
findings: step.findings,
|
|
330
|
+
findings: findingsForReader(step.findings, reader),
|
|
314
331
|
// Never gated on `ok` or `--verbose`, unlike `output` below: see `StepResult.warnings`.
|
|
315
332
|
...(step.warnings === undefined || step.warnings.length === 0
|
|
316
333
|
? {}
|
|
@@ -348,14 +365,21 @@ export function renderJson(result: CommandResult, verbose = false): string {
|
|
|
348
365
|
command: result.command,
|
|
349
366
|
summary: result.summary,
|
|
350
367
|
...(result.steps === undefined ? {} : { steps }),
|
|
351
|
-
...(result.findings === undefined
|
|
368
|
+
...(result.findings === undefined
|
|
369
|
+
? {}
|
|
370
|
+
: { findings: findingsForReader(result.findings, reader) }),
|
|
352
371
|
...(result.data === undefined ? {} : { data: result.data }),
|
|
353
372
|
};
|
|
354
373
|
return JSON.stringify(payload);
|
|
355
374
|
}
|
|
356
375
|
|
|
357
|
-
export function render(
|
|
358
|
-
|
|
376
|
+
export function render(
|
|
377
|
+
result: CommandResult,
|
|
378
|
+
json: boolean,
|
|
379
|
+
verbose = false,
|
|
380
|
+
reader: ReaderOptions = {},
|
|
381
|
+
): string {
|
|
382
|
+
return json ? renderJson(result, verbose, reader) : renderHuman(result, verbose, reader);
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
export function exitCodeFor(result: CommandResult): number {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `packages/` may not import `apps/`: a shared package that depends on one app is no longer shared.
|
|
2
|
+
// Its own rule and its own read, beside `app-boundaries.ts`: `readAppSources` is what the surface
|
|
3
|
+
// graph walks, and a package source joining that graph would change every surface verdict too.
|
|
4
|
+
// No per-package tiers: the layout of an app's `packages/` is the app's.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'; // why: a synchronous directory probe; Bun.file answers files.
|
|
7
|
+
// why: Bun ships no `Bun.*` path API: `joinPath` reaches a file on disk with the host's separator.
|
|
8
|
+
import { join as joinPath } from 'node:path';
|
|
9
|
+
// why: no Bun path API; the POSIX variants resolve specifiers against repo-relative keys, which
|
|
10
|
+
// are POSIX on every host.
|
|
11
|
+
import { dirname, join, normalize } from 'node:path/posix';
|
|
12
|
+
import { ERROR_DOCS_URL, stringField } from '@ultimat3/core';
|
|
13
|
+
import type { SourceFile } from './app-boundaries';
|
|
14
|
+
import { commentSafe } from './comment-safe';
|
|
15
|
+
import { scanRuntimeImports } from './import-scan';
|
|
16
|
+
import type { Finding } from './output';
|
|
17
|
+
import { hasPathSegment } from './path-segments';
|
|
18
|
+
import { toPosix } from './posix-path';
|
|
19
|
+
import { isEntityModule } from './templates/entity-module';
|
|
20
|
+
|
|
21
|
+
const DB_SOURCES = 'packages/db/src/';
|
|
22
|
+
|
|
23
|
+
/** Each app workspace's package name → its directory: `@acme/web` → `apps/web`. */
|
|
24
|
+
export type AppWorkspaces = ReadonlyMap<string, string>;
|
|
25
|
+
|
|
26
|
+
/** Where a specifier lands inside `apps/`, or `undefined` when it lands anywhere else. */
|
|
27
|
+
const appTarget = (
|
|
28
|
+
from: string,
|
|
29
|
+
specifier: string,
|
|
30
|
+
workspaces: AppWorkspaces,
|
|
31
|
+
): string | undefined => {
|
|
32
|
+
if (specifier.startsWith('.')) {
|
|
33
|
+
const target = normalize(join(dirname(from), specifier));
|
|
34
|
+
return target.startsWith('apps/') ? target : undefined;
|
|
35
|
+
}
|
|
36
|
+
if (specifier.startsWith('apps/')) return specifier;
|
|
37
|
+
for (const [name, dir] of workspaces) {
|
|
38
|
+
if (specifier === name) return dir;
|
|
39
|
+
if (specifier.startsWith(`${name}/`)) return `${dir}${specifier.slice(name.length)}`;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one generated exception: `x g entity` adds its entity to the typed handle and `x new` to
|
|
46
|
+
* `schema.ts`, both in `packages/db/src/`, reading it from the slice that declares it
|
|
47
|
+
* (`templates/scaffold-db-client.ts`). An entity module, exactly: a `repo` there is a violation.
|
|
48
|
+
*/
|
|
49
|
+
const isDbEntry = (from: string, target: string): boolean =>
|
|
50
|
+
from.startsWith(DB_SOURCES) && isEntityModule(`${target}.ts`);
|
|
51
|
+
|
|
52
|
+
/** Pure, so a caller holding the text needs no disk. Runtime imports only: `import type` erases. */
|
|
53
|
+
export function packageToAppFindings(
|
|
54
|
+
files: readonly SourceFile[],
|
|
55
|
+
workspaces: AppWorkspaces,
|
|
56
|
+
): readonly Finding[] {
|
|
57
|
+
return files.flatMap((file) =>
|
|
58
|
+
scanRuntimeImports(file).flatMap((specifier) => {
|
|
59
|
+
const target = appTarget(file.path, specifier, workspaces);
|
|
60
|
+
if (target === undefined || isDbEntry(file.path, target)) return [];
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
code: 'X_BOUNDARY_PACKAGE_TO_APP',
|
|
64
|
+
cause: `package source imports an app ("${specifier}", in ${target}) — a package an app depends on cannot depend back on one app`,
|
|
65
|
+
fix: `x verify --only boundaries --json # after moving ${commentSafe(target)} into the package, or importing in the opposite direction`,
|
|
66
|
+
docs: ERROR_DOCS_URL,
|
|
67
|
+
at: file.path,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PACKAGE_GLOB = 'packages/*/**/*.{ts,tsx}';
|
|
75
|
+
|
|
76
|
+
/** Every package source of an app: never a test, a dependency or a build's output. */
|
|
77
|
+
export async function readPackageSources(root: string): Promise<readonly SourceFile[]> {
|
|
78
|
+
const files: SourceFile[] = [];
|
|
79
|
+
for await (const path of new Bun.Glob(PACKAGE_GLOB).scan({ cwd: root, absolute: false })) {
|
|
80
|
+
if (hasPathSegment(path, 'node_modules') || hasPathSegment(path, 'dist')) continue;
|
|
81
|
+
if (path.includes('.test.') || path.endsWith('.d.ts')) continue;
|
|
82
|
+
const posix = toPosix(path);
|
|
83
|
+
files.push({ path: posix, source: await Bun.file(joinPath(root, posix)).text() });
|
|
84
|
+
}
|
|
85
|
+
return files.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `apps/*\/package.json`'s names, or `undefined` for a tree that holds no app at all. */
|
|
89
|
+
const readAppWorkspaces = async (root: string): Promise<AppWorkspaces | undefined> => {
|
|
90
|
+
// A root with no `apps/` at all, before the walk: Bun's glob throws on a missing directory.
|
|
91
|
+
if (!existsSync(joinPath(root, 'apps'))) return undefined;
|
|
92
|
+
const found = new Map<string, string>();
|
|
93
|
+
for await (const path of new Bun.Glob('apps/*/package.json').scan({ cwd: root })) {
|
|
94
|
+
const manifest: unknown = await Bun.file(joinPath(root, path)).json();
|
|
95
|
+
// `stringField`: a parsed manifest is a value this process did not build (`caught-value-reads`).
|
|
96
|
+
const name = stringField(manifest, 'name');
|
|
97
|
+
if (name !== undefined) found.set(name, dirname(toPosix(path)));
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The rule over a real tree. A tree with no `apps/` answers nothing: that is the framework's own
|
|
104
|
+
* repository, whose `packages/` is 3,000 files this rule has nothing to say about.
|
|
105
|
+
*/
|
|
106
|
+
export async function packageBoundaryFindings(root: string): Promise<readonly Finding[]> {
|
|
107
|
+
const workspaces = await readAppWorkspaces(root);
|
|
108
|
+
if (workspaces === undefined) return [];
|
|
109
|
+
return packageToAppFindings(await readPackageSources(root), workspaces);
|
|
110
|
+
}
|
package/src/page-pins.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The pins a rendered document stamps (`@ultimat3/render`'s `clientPinTags`): every registered
|
|
2
|
+
// action and query that declares `http: { path }`, read off the registries per render, so a
|
|
3
|
+
// browser caller reaching one by NAME goes where the declaration put it — nothing restated.
|
|
4
|
+
|
|
5
|
+
import { actionHttpPinOf, describeActions, getAction } from '@ultimat3/action';
|
|
6
|
+
import type { ActionPin, ClientPins } from '@ultimat3/core';
|
|
7
|
+
import { describeQueries, getQuery, queryHttpPinOf } from '@ultimat3/query';
|
|
8
|
+
|
|
9
|
+
export function servedPins(): ClientPins {
|
|
10
|
+
const actions: Record<string, ActionPin> = {};
|
|
11
|
+
for (const { name } of describeActions()) {
|
|
12
|
+
const target = getAction(name);
|
|
13
|
+
const pin = target === undefined ? undefined : actionHttpPinOf(target);
|
|
14
|
+
if (pin !== undefined) actions[name] = pin;
|
|
15
|
+
}
|
|
16
|
+
const queries: Record<string, string> = {};
|
|
17
|
+
for (const { name } of describeQueries()) {
|
|
18
|
+
const target = getQuery(name);
|
|
19
|
+
const pin = target === undefined ? undefined : queryHttpPinOf(target);
|
|
20
|
+
if (pin !== undefined) queries[name] = pin.path;
|
|
21
|
+
}
|
|
22
|
+
return { actions, queries };
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The plain routes both boots mount and `x routes` lists: `apps/<app>/runtime.ts`'s `routes`, then
|
|
2
|
+
// every declared `webhookReceiver()`'s. Declaring a receiver is its one registration; a route the
|
|
3
|
+
// app also named by hand is mounted once, never twice (`X_ROUTE_CONFLICT`).
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import { webhookReceiverRoutes } from '@ultimat3/jobs';
|
|
7
|
+
|
|
8
|
+
/** Called after the app's modules loaded: the receivers are read off the job registry. */
|
|
9
|
+
export function plainRoutes(declared: readonly Route[] | undefined): readonly Route[] {
|
|
10
|
+
const own = declared ?? [];
|
|
11
|
+
const named = new Set(own);
|
|
12
|
+
return [...own, ...webhookReceiverRoutes().filter((route) => !named.has(route))];
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The pages each public `site/` route's `prerender()` lists, enumerated ONCE by the static export
|
|
2
|
+
// while its in-process API scope and database are still open — and handed to `siteSeo` on the
|
|
3
|
+
// report, so the sitemap never asks `prerender()` again after the build released both.
|
|
4
|
+
|
|
5
|
+
import type { PrerenderEntry, RouteEntry } from '@ultimat3/render';
|
|
6
|
+
import { enumeratePrerenderEntries } from '@ultimat3/render/server';
|
|
7
|
+
|
|
8
|
+
/** Every route's enumeration, by its DECLARED path (`/posts/:slug`). */
|
|
9
|
+
export type RouteEntries = ReadonlyMap<string, readonly PrerenderEntry[]>;
|
|
10
|
+
|
|
11
|
+
export type EnumerateRoute = (entry: RouteEntry) => Promise<readonly PrerenderEntry[]>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `enumeratePrerenderEntries`, asked at most once per route: the build hands the one memo to the
|
|
15
|
+
* weigh-only branch (`measurePaths`) and to `enumerateRoutes`, so a budgeted dynamic route's
|
|
16
|
+
* `prerender()` runs once per build.
|
|
17
|
+
*/
|
|
18
|
+
export function enumerateOnce(): EnumerateRoute {
|
|
19
|
+
const asked = new Map<string, Promise<readonly PrerenderEntry[]>>();
|
|
20
|
+
return (entry) => {
|
|
21
|
+
const known = asked.get(entry.path);
|
|
22
|
+
if (known !== undefined) return known;
|
|
23
|
+
const answer = enumeratePrerenderEntries(entry);
|
|
24
|
+
asked.set(entry.path, answer);
|
|
25
|
+
return answer;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A route `siteSeo` would ask: public `site/` with a `prerender()` declared. One without has
|
|
31
|
+
* nothing to enumerate (`enumeratePrerenderEntries` answers from the pattern alone), so an app with
|
|
32
|
+
* none starts no database for it.
|
|
33
|
+
*/
|
|
34
|
+
export const asksPrerender = (entry: RouteEntry): boolean =>
|
|
35
|
+
entry.surface === 'site' &&
|
|
36
|
+
entry.config.policy === undefined &&
|
|
37
|
+
entry.config.prerender !== undefined;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Each of `routes` that `asksPrerender`, enumerated in turn inside `scope` — the build's measuring
|
|
41
|
+
* scope, where a `prerender()` reading the app's own query is answered. A throw is the build's
|
|
42
|
+
* failure, exactly as it was when the sitemap made the call: a `prerender()` that cannot list its
|
|
43
|
+
* pages is the app's bug, never a page silently left out.
|
|
44
|
+
*/
|
|
45
|
+
export async function enumerateRoutes(
|
|
46
|
+
routes: readonly RouteEntry[],
|
|
47
|
+
scope: <T>(fn: () => Promise<T>) => Promise<T>,
|
|
48
|
+
before: () => Promise<void>,
|
|
49
|
+
enumerate: EnumerateRoute = enumeratePrerenderEntries,
|
|
50
|
+
): Promise<RouteEntries> {
|
|
51
|
+
const asked = routes.filter(asksPrerender);
|
|
52
|
+
const entries = new Map<string, readonly PrerenderEntry[]>();
|
|
53
|
+
if (asked.length === 0) return entries;
|
|
54
|
+
await before();
|
|
55
|
+
for (const entry of asked) {
|
|
56
|
+
entries.set(entry.path, await scope(() => enumerate(entry)));
|
|
57
|
+
}
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Single responsibility: name the heaviest thing a document boots by the source file an author can
|
|
2
|
+
// open. Split from `prerender.ts`, which stands at its size ceiling.
|
|
3
|
+
|
|
4
|
+
import type { IslandBundle } from './island-bundle';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The heaviest thing the document actually boots, named by the source file an author can open.
|
|
8
|
+
* An island chunk is content-addressed, so its URL says nothing on its own — mapping it back
|
|
9
|
+
* through the bundle is what turns `X_BUDGET_EXCEEDED` from a number into an instruction.
|
|
10
|
+
*/
|
|
11
|
+
export function heaviestSource(
|
|
12
|
+
bundle: IslandBundle,
|
|
13
|
+
entries: readonly { readonly url: string; readonly bytes: number }[],
|
|
14
|
+
): readonly string[] | undefined {
|
|
15
|
+
const heaviest = entries.reduce<{ url: string; bytes: number } | undefined>(
|
|
16
|
+
(max, entry) => (max === undefined || entry.bytes > max.bytes ? entry : max),
|
|
17
|
+
undefined,
|
|
18
|
+
);
|
|
19
|
+
if (heaviest === undefined) return undefined;
|
|
20
|
+
const chunk = bundle.chunkAt(heaviest.url);
|
|
21
|
+
if (chunk !== undefined) return [chunk.file];
|
|
22
|
+
// A shared chunk has no file of its own: the chain is an island on THIS page that imports it.
|
|
23
|
+
const importer = bundle.chunks.find(
|
|
24
|
+
(one) => one.imports.includes(heaviest.url) && entries.some((entry) => entry.url === one.url),
|
|
25
|
+
);
|
|
26
|
+
return importer === undefined ? [heaviest.url] : [importer.file, heaviest.url];
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Preloaded into `x build --target static`'s `bun run apps/web/prerender.ts`: installs the render
|
|
2
|
+
// loader BEFORE Bun parses the script's import graph. A `.tsx` the script reaches statically (a
|
|
3
|
+
// shared module that imports `@ultimat3/ui`) is parsed first, so a loader installed by evaluating
|
|
4
|
+
// `@ultimat3/render` inside that graph arrives too late and Bun compiles the JSX to `jsxDEV`.
|
|
5
|
+
|
|
6
|
+
import '@ultimat3/render/server';
|
package/src/prerender-report.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// where the stats and the inventory landed. Split from `prerender.ts`, which decides what is
|
|
3
3
|
// written; this file only names the shapes the report carries.
|
|
4
4
|
|
|
5
|
+
import type { RouteEntries } from './prerender-entries';
|
|
6
|
+
import type { LlmsPages } from './site-llms-pages';
|
|
5
7
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
6
8
|
|
|
7
9
|
export interface PrerenderedPage {
|
|
@@ -55,4 +57,14 @@ export interface PrerenderReport {
|
|
|
55
57
|
readonly warnings: readonly string[];
|
|
56
58
|
/** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
|
|
57
59
|
readonly origin: string;
|
|
60
|
+
/**
|
|
61
|
+
* Every page each public `site/` route's `prerender()` listed, enumerated once inside the build's
|
|
62
|
+
* scope — hand it to `siteSeo`, which then never asks `prerender()` after that scope is gone.
|
|
63
|
+
*/
|
|
64
|
+
readonly entries: RouteEntries;
|
|
65
|
+
/**
|
|
66
|
+
* Every `llms.txt` page record whose `meta` needs its `load`, read inside the build's scope —
|
|
67
|
+
* hand it to `siteSeo` as `llmsPages`, which then never runs a `load` after that scope is gone.
|
|
68
|
+
*/
|
|
69
|
+
readonly llmsPages: LlmsPages;
|
|
58
70
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// What `siteSeo` needs from the static build, read while its API scope and database are still open:
|
|
2
|
+
// each `site/` route's `prerender()` entries and each `llms.txt` page's `load`. Asked after the
|
|
3
|
+
// scope closes, a route that reads the app's own query fails the export.
|
|
4
|
+
|
|
5
|
+
import { routeEntries } from '@ultimat3/render';
|
|
6
|
+
import { type EnumerateRoute, enumerateRoutes, type RouteEntries } from './prerender-entries';
|
|
7
|
+
import { loadSiteSettings } from './site-config';
|
|
8
|
+
import { collectLlmsPages, type LlmsPages } from './site-llms-pages';
|
|
9
|
+
|
|
10
|
+
export interface SeoReadsInput {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly origin: string;
|
|
13
|
+
readonly defaultLocale: string;
|
|
14
|
+
/** Runs one read inside the build's PUBLIC scope: its in-process API authenticates nobody. */
|
|
15
|
+
readonly scope: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
16
|
+
/** Readies the build's database before the first read that needs it. */
|
|
17
|
+
readonly before: () => Promise<void>;
|
|
18
|
+
/** The build's one enumeration memo, so a route's `prerender()` runs once per build. */
|
|
19
|
+
readonly enumerate: EnumerateRoute;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SeoReads {
|
|
23
|
+
readonly entries: RouteEntries;
|
|
24
|
+
readonly llmsPages: LlmsPages;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function collectSeoReads(input: SeoReadsInput): Promise<SeoReads> {
|
|
28
|
+
const entries = await enumerateRoutes(routeEntries(), input.scope, input.before, input.enumerate);
|
|
29
|
+
const llmsPages = await collectLlmsPages({
|
|
30
|
+
baseUrl: input.origin,
|
|
31
|
+
defaultLocale: input.defaultLocale,
|
|
32
|
+
enabled: (await loadSiteSettings(input.root)).llms,
|
|
33
|
+
entries,
|
|
34
|
+
scope: input.scope,
|
|
35
|
+
before: input.before,
|
|
36
|
+
});
|
|
37
|
+
return { entries, llmsPages };
|
|
38
|
+
}
|
package/src/prerender-served.ts
CHANGED
|
@@ -9,14 +9,21 @@ import type { MeasureOptions } from './budgets';
|
|
|
9
9
|
import type { IslandBundle } from './island-bundle';
|
|
10
10
|
import { isRuntimeChunk } from './island-runtime';
|
|
11
11
|
import { pageSync } from './page-sync';
|
|
12
|
+
import type { VitalsBuild } from './prerender-vitals';
|
|
12
13
|
import { realtimeConfigOf } from './runtime-realtime';
|
|
13
14
|
import { PAGE_BOOT_BASE_PATH } from './worker-bundle';
|
|
15
|
+
import { zoneBoot } from './zone-boot';
|
|
14
16
|
|
|
15
17
|
/** What a served-document measurement adds: to the document's options, and to the measurer's. */
|
|
16
18
|
export interface ServedMeasure {
|
|
17
19
|
readonly document: {
|
|
18
20
|
readonly sync?: ClientSyncHead;
|
|
19
21
|
readonly persisted?: () => readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* `/x-client-vitals.js`, when `clientErrors.vitals` lists `app`, and the zone script when
|
|
24
|
+
* `time.detect` is on: served, so charged — both are the app's opt-in.
|
|
25
|
+
*/
|
|
26
|
+
readonly appHead?: string;
|
|
20
27
|
};
|
|
21
28
|
readonly measure: MeasureOptions;
|
|
22
29
|
}
|
|
@@ -24,25 +31,33 @@ export interface ServedMeasure {
|
|
|
24
31
|
/**
|
|
25
32
|
* `realtime.enabled: false` serves no sync head and no boot (`pageSync`), and then the measurement
|
|
26
33
|
* adds nothing: such a page's realtime islands load the runtime chunk, and are charged for it.
|
|
34
|
+
*
|
|
35
|
+
* Real-user timings ride the same way: an `app/` document of an app that listed `app` names
|
|
36
|
+
* `/x-client-vitals.js`, which no artifact holds, so its bytes come from `vitals.script`. Charged,
|
|
37
|
+
* not exempted (`budgets.ts`' `FRAMEWORK_SCRIPTS`): the app opted in, and can opt out.
|
|
27
38
|
*/
|
|
28
39
|
export async function servedMeasure(
|
|
29
40
|
root: string,
|
|
30
41
|
buildId: string,
|
|
31
42
|
islands: IslandBundle,
|
|
43
|
+
vitals: VitalsBuild = {},
|
|
32
44
|
): Promise<ServedMeasure> {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
buildId,
|
|
37
|
-
realtimeConfigOf(await loadAppConfig(root)),
|
|
38
|
-
);
|
|
45
|
+
const config = await loadAppConfig(root);
|
|
46
|
+
const sync = await pageSync(root, process.env, buildId, realtimeConfigOf(config));
|
|
47
|
+
const appHead = (zoneBoot(config?.time)?.head ?? '') + (vitals.appHead ?? '');
|
|
39
48
|
return {
|
|
40
49
|
document: {
|
|
41
50
|
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
42
51
|
persisted: sync.persisted,
|
|
52
|
+
...(appHead === '' ? {} : { appHead }),
|
|
43
53
|
},
|
|
44
54
|
measure: {
|
|
45
|
-
served: new Map(
|
|
55
|
+
served: new Map([
|
|
56
|
+
...sync.scripts.map((script): [string, string] => [script.url, script.code]),
|
|
57
|
+
...(vitals.appHead === undefined || vitals.script === undefined
|
|
58
|
+
? []
|
|
59
|
+
: [[vitals.script.url, vitals.script.code] as [string, string]]),
|
|
60
|
+
]),
|
|
46
61
|
boot: {
|
|
47
62
|
prefix: `${PAGE_BOOT_BASE_PATH}/`,
|
|
48
63
|
supplies: new Set(islands.shared.map((one) => one.url).filter(isRuntimeChunk)),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// What the build adds for real-user timings (`clientErrors.vitals`): the head each surface's
|
|
2
|
+
// documents carry and the script's bytes, so the budgets step weighs a page as it is SERVED. Off,
|
|
3
|
+
// or a surface not listed, adds nothing — and a page that carries nothing is charged nothing.
|
|
4
|
+
|
|
5
|
+
import type { ClientVitalsConfig } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
CLIENT_VITALS_PATH,
|
|
8
|
+
CLIENT_VITALS_SCRIPT_PATH,
|
|
9
|
+
clientVitalsHead,
|
|
10
|
+
clientVitalsScript,
|
|
11
|
+
} from './client-vitals-script';
|
|
12
|
+
|
|
13
|
+
export interface VitalsBuild {
|
|
14
|
+
/** For an `app/` document weighed as served. Absent unless `app` is listed. */
|
|
15
|
+
readonly appHead?: string;
|
|
16
|
+
/** For a `site/` document, exported and weighed. Absent unless `site` is listed. */
|
|
17
|
+
readonly siteHead?: string;
|
|
18
|
+
/** The bytes behind the head, keyed by the URL a document names. Absent when off. */
|
|
19
|
+
readonly script?: { readonly url: string; readonly code: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function vitalsBuild(config: ClientVitalsConfig | undefined): VitalsBuild {
|
|
23
|
+
if (config?.enabled !== true) return {};
|
|
24
|
+
const head = clientVitalsHead();
|
|
25
|
+
return {
|
|
26
|
+
...(config.surfaces.includes('app') ? { appHead: head } : {}),
|
|
27
|
+
...(config.surfaces.includes('site') ? { siteHead: head } : {}),
|
|
28
|
+
script: { url: CLIENT_VITALS_SCRIPT_PATH, code: clientVitalsScript(config.sampleRate) },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A `site/` page exported with the script names a beacon route only a served process answers:
|
|
34
|
+
* on a static host the timings go nowhere. Said, never refused — the same export served by the
|
|
35
|
+
* container does land them, and the bytes are charged either way.
|
|
36
|
+
*/
|
|
37
|
+
export function clientVitalsStaticWarning(build: VitalsBuild): string[] {
|
|
38
|
+
if (build.siteHead === undefined) return [];
|
|
39
|
+
return [
|
|
40
|
+
`clientErrors.vitals.surfaces lists site, and a static host serves no ${CLIENT_VITALS_PATH} route — exported site/ pages carry ${CLIENT_VITALS_SCRIPT_PATH}, and their beacons land only where the app is served (x dev, or the container)`,
|
|
41
|
+
];
|
|
42
|
+
}
|