@ultimat3/cli 29.4.0 → 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 +11 -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 +9 -0
- 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 +41 -81
- 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 +7 -1
- 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,116 @@
|
|
|
1
|
+
// An island's `mount` hands back its disposer, made a build error: the client router disposes an
|
|
2
|
+
// island by calling what `mount` resolved to (`disposeIslands`), so a `mount(): void` is never
|
|
3
|
+
// released and its listeners, hotkey tables and live reads stay registered across navigations.
|
|
4
|
+
// Pure over the `boundaries` step's one read; no type can carry it, an island is a specifier.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
|
|
7
|
+
import { ISLAND_EXTENSION } from '@ultimat3/render';
|
|
8
|
+
import type { SourceFile } from './app-boundaries';
|
|
9
|
+
import { commentSafe } from './comment-safe';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
|
|
12
|
+
/** `function mount(` and `async function mount<T>(` — the declaration form. */
|
|
13
|
+
const DECLARED = /\b(?:async\s+)?function\s+mount\s*(?:<[^>]*>)?\s*\(/;
|
|
14
|
+
/** `const mount = (`, `= async (`, `= function (` — an unannotated binding. */
|
|
15
|
+
const BOUND = /\b(?:const|let|var)\s+mount\s*=\s*(?:async\s+)?(?:function\b[^(]*)?\(/;
|
|
16
|
+
/** The annotations that SAY nothing comes back; any other one is the type checker's to hold. */
|
|
17
|
+
const VOID_ANNOTATION = /^:\s*(?:void|undefined|Promise\s*<\s*(?:void|undefined)\s*>)\s*$/;
|
|
18
|
+
/** A `(…) {` that opens a statement block, not a function's body. */
|
|
19
|
+
const CONTROL_WORDS: ReadonlySet<string> = new Set([
|
|
20
|
+
'if',
|
|
21
|
+
'for',
|
|
22
|
+
'while',
|
|
23
|
+
'switch',
|
|
24
|
+
'catch',
|
|
25
|
+
'with',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** The index closing the bracket opened at `open`, or `-1`. Exact over `maskLiterals` output. */
|
|
29
|
+
function closeOf(masked: string, open: number): number {
|
|
30
|
+
const opener = masked[open];
|
|
31
|
+
const closer = opener === '(' ? ')' : '}';
|
|
32
|
+
let depth = 0;
|
|
33
|
+
for (let index = open; index < masked.length; index += 1) {
|
|
34
|
+
if (masked[index] === opener) depth += 1;
|
|
35
|
+
else if (masked[index] === closer) {
|
|
36
|
+
depth -= 1;
|
|
37
|
+
if (depth === 0) return index;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `(` matching the `)` at `close`, scanning backwards. */
|
|
44
|
+
function openOf(masked: string, close: number): number {
|
|
45
|
+
let depth = 0;
|
|
46
|
+
for (let index = close; index >= 0; index -= 1) {
|
|
47
|
+
if (masked[index] === ')') depth += 1;
|
|
48
|
+
else if (masked[index] === '(') {
|
|
49
|
+
depth -= 1;
|
|
50
|
+
if (depth === 0) return index;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether the `{` at `brace` opens a nested function's body: `=> {`, `function f() {`, `m() {`. */
|
|
57
|
+
function opensFunction(masked: string, brace: number): boolean {
|
|
58
|
+
const before = masked.slice(0, brace).trimEnd();
|
|
59
|
+
if (before.endsWith('=>')) return true;
|
|
60
|
+
if (!before.endsWith(')')) return false;
|
|
61
|
+
const open = openOf(masked, before.length - 1);
|
|
62
|
+
const word = /([\w$]+)\s*$/.exec(masked.slice(0, Math.max(0, open)))?.[1];
|
|
63
|
+
return word !== undefined && !CONTROL_WORDS.has(word);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether the body opened at `brace` returns a value from its OWN scope, not a nested function's. */
|
|
67
|
+
function returnsValue(masked: string, brace: number): boolean {
|
|
68
|
+
const end = closeOf(masked, brace);
|
|
69
|
+
const stack: boolean[] = [];
|
|
70
|
+
for (let index = brace + 1; index < (end < 0 ? masked.length : end); index += 1) {
|
|
71
|
+
const char = masked[index];
|
|
72
|
+
if (char === '{') stack.push(opensFunction(masked, index));
|
|
73
|
+
else if (char === '(' || char === '[') stack.push(false);
|
|
74
|
+
else if (char === '}' || char === ')' || char === ']') stack.pop();
|
|
75
|
+
else if (stack.includes(true) || !masked.startsWith('return', index)) continue;
|
|
76
|
+
else if (/[\w$]/.test(masked[index - 1] ?? '') || /[\w$]/.test(masked[index + 6] ?? '')) {
|
|
77
|
+
} else if (/^[ \t]*[^\s;}]/.test(masked.slice(index + 6))) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Why `masked`'s mount returns no disposer, or `undefined` when it does or none is declared. */
|
|
83
|
+
function missingDisposer(masked: string): string | undefined {
|
|
84
|
+
const match = DECLARED.exec(masked) ?? BOUND.exec(masked);
|
|
85
|
+
if (match === null) return undefined;
|
|
86
|
+
const close = closeOf(masked, match.index + match[0].length - 1);
|
|
87
|
+
if (close < 0) return undefined;
|
|
88
|
+
const after = masked.slice(close + 1);
|
|
89
|
+
const arrow = DECLARED.exec(masked) === null;
|
|
90
|
+
const head = arrow ? after.slice(0, Math.max(0, after.indexOf('=>'))) : after.split('{')[0];
|
|
91
|
+
const annotation = (head ?? '').trim();
|
|
92
|
+
if (VOID_ANNOTATION.test(annotation)) return `is typed \`${annotation.slice(1).trim()}\``;
|
|
93
|
+
if (annotation !== '') return undefined;
|
|
94
|
+
const bodyAt = arrow ? close + 1 + after.indexOf('=>') + 2 : close + 1 + after.indexOf('{');
|
|
95
|
+
const lead = masked.slice(bodyAt).match(/^\s*/)?.[0].length ?? 0;
|
|
96
|
+
if (masked[bodyAt + lead] !== '{') return undefined;
|
|
97
|
+
return returnsValue(masked, bodyAt + lead) ? undefined : 'returns nothing from its own body';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One `X_ISLAND_DISPOSER_MISSING` per `.island.tsx` whose `mount` hands back no disposer. */
|
|
101
|
+
export function islandDisposerFindings(files: readonly SourceFile[]): readonly Finding[] {
|
|
102
|
+
return files.flatMap(({ path, source }): Finding[] => {
|
|
103
|
+
if (!path.endsWith(ISLAND_EXTENSION) || !source.includes('mount')) return [];
|
|
104
|
+
const why = missingDisposer(maskLiterals(source));
|
|
105
|
+
if (why === undefined) return [];
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
code: 'X_ISLAND_DISPOSER_MISSING',
|
|
109
|
+
cause: `${path}'s mount() ${why}, so the client router has nothing to call when it navigates away and the island's listeners, hotkeys and live reads stay registered`,
|
|
110
|
+
fix: `x verify --only boundaries --json # in ${commentSafe(path)}, end mount() with return render(() => <Island {...props} />, el) — Solid's disposer, which the router calls`,
|
|
111
|
+
docs: ERROR_DOCS_URL,
|
|
112
|
+
at: path,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// whole realtime use is a channel's events, a roster or the connection reads neither: its server
|
|
5
5
|
// markup is as current as any other island's, so hiding it only delayed the page's content.
|
|
6
6
|
|
|
7
|
+
import { registeredEntities } from '@ultimat3/entity';
|
|
7
8
|
import { firstInGraph } from './live-routes';
|
|
8
9
|
|
|
9
10
|
const REALTIME = '@ultimat3/realtime';
|
|
@@ -20,6 +21,8 @@ export const FOLLOW_ONLY_EXPORTS: readonly string[] = [
|
|
|
20
21
|
'hasPageSocket',
|
|
21
22
|
// Hears THAT records moved and renders none of them, so a restored store contradicts nothing.
|
|
22
23
|
'useRecordChanges',
|
|
24
|
+
// A timer value for `useQuery`'s `poll`: it reads nothing, so it adds no reason to hold.
|
|
25
|
+
'pollEvery',
|
|
23
26
|
'readPresence',
|
|
24
27
|
'channel',
|
|
25
28
|
'channelRef',
|
|
@@ -63,17 +66,42 @@ export function realtimeBindings(source: string, path: string): readonly string[
|
|
|
63
66
|
*/
|
|
64
67
|
export type IslandRealtime = 'restores' | 'follows';
|
|
65
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The reads that restore something only when the app keeps a record type on disk: with none, the
|
|
71
|
+
* boot restores no record their server markup could be older than. `useMutation` is not here —
|
|
72
|
+
* the outbox rebuilds a queued write's overlay whatever the app persists.
|
|
73
|
+
*/
|
|
74
|
+
export const NO_DISK_FOLLOW_EXPORTS: readonly string[] = ['useQuery', 'useRecord'];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the loaded app keeps any record type on disk (`entity(…, { persist: true })`), read off
|
|
78
|
+
* the entity registry the build process loaded the app into. An empty registry is `undefined`: an
|
|
79
|
+
* app that registered nothing and a build that never loaded it look the same, so it is unknown.
|
|
80
|
+
*/
|
|
81
|
+
export function appPersistsRecords(): boolean | undefined {
|
|
82
|
+
const entries = registeredEntities();
|
|
83
|
+
if (entries.length === 0) return undefined;
|
|
84
|
+
return entries.some((entry) => entry.persist === true);
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
/**
|
|
67
88
|
* Whether a realtime island restores nothing: every module of its relative graph takes only
|
|
68
|
-
* `FOLLOW_ONLY_EXPORTS` from the barrel
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
89
|
+
* `FOLLOW_ONLY_EXPORTS` from the barrel — plus `NO_DISK_FOLLOW_EXPORTS` when `persists` is
|
|
90
|
+
* `false`. One unknown name, or one statement with no names, and it restores — the answer every
|
|
91
|
+
* realtime island had before; `persists` unknown keeps that answer. Pure: asked per build, of that
|
|
92
|
+
* build's root. The blind spot is `reachesRealtime`'s own: a PACKAGE reading the store for the
|
|
93
|
+
* island is not seen.
|
|
72
94
|
*/
|
|
73
|
-
export async function followsOnly(
|
|
95
|
+
export async function followsOnly(
|
|
96
|
+
root: string,
|
|
97
|
+
file: string,
|
|
98
|
+
persists?: boolean,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const allowed =
|
|
101
|
+
persists === false ? [...FOLLOW_ONLY_EXPORTS, ...NO_DISK_FOLLOW_EXPORTS] : FOLLOW_ONLY_EXPORTS;
|
|
74
102
|
const restores = await firstInGraph(root, file, (source, path) => {
|
|
75
103
|
const names = realtimeBindings(source, path);
|
|
76
|
-
const follows = names?.every((name) =>
|
|
104
|
+
const follows = names?.every((name) => allowed.includes(name)) === true;
|
|
77
105
|
return follows ? undefined : true;
|
|
78
106
|
});
|
|
79
107
|
return restores !== true;
|
package/src/island-realtime.ts
CHANGED
|
@@ -50,14 +50,18 @@ export async function reachesRealtime(root: string, file: string): Promise<boole
|
|
|
50
50
|
* Both of a build's answers about one island, for its chunk to carry (`IslandChunk.realtime`):
|
|
51
51
|
* `undefined` when its graph never reaches realtime. A page needs realtime's page boot only if one
|
|
52
52
|
* of ITS islands does — `settings` paid 34.9 kB of boot script for an island that never touched a
|
|
53
|
-
* record — and holds only the ones that restore.
|
|
53
|
+
* record — and holds only the ones that restore. `persists` is REQUIRED, and the build passes the
|
|
54
|
+
* loaded app's registry's answer (`appPersistsRecords`): every boot that builds islands has imported
|
|
55
|
+
* the app first. As a default it read process-wide state here, so one test's entities changed
|
|
56
|
+
* another's answer (`bun test ./packages/cli`, alone, went red); `undefined` is "unknown".
|
|
54
57
|
*/
|
|
55
58
|
export async function realtimeKind(
|
|
56
59
|
root: string,
|
|
57
60
|
file: string,
|
|
61
|
+
persists: boolean | undefined,
|
|
58
62
|
): Promise<IslandRealtime | undefined> {
|
|
59
63
|
if (!(await reachesRealtime(root, file))) return undefined;
|
|
60
|
-
return (await followsOnly(root, file)) ? 'follows' : 'restores';
|
|
64
|
+
return (await followsOnly(root, file, persists)) ? 'follows' : 'restores';
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
package/src/island-routes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// would be a page that boots in `x dev` and 404s in the image.
|
|
4
4
|
|
|
5
5
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
6
|
-
import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
|
|
6
|
+
import { applyCacheHeaders, jsonResponse, NO_STORE, textResponse } from '@ultimat3/http';
|
|
7
7
|
import type { IslandBundle } from './island-bundle';
|
|
8
8
|
import { ISLAND_BASE_PATH } from './island-bundle';
|
|
9
9
|
|
|
@@ -51,7 +51,8 @@ export function islandRoutes(source: IslandSource): readonly Route[] {
|
|
|
51
51
|
return applyCacheHeaders(missing, NO_STORE);
|
|
52
52
|
}
|
|
53
53
|
return applyCacheHeaders(
|
|
54
|
-
|
|
54
|
+
// `textResponse` for the length it declares: `compress` reads nothing undeclared.
|
|
55
|
+
textResponse(chunk.code, { headers: { 'content-type': 'text/javascript' } }),
|
|
55
56
|
{ mode: 'immutable' },
|
|
56
57
|
);
|
|
57
58
|
},
|
package/src/isr-query-advice.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What `x verify` says about the query an `isr` route keys on. Advice and not a finding: the scan
|
|
2
2
|
// reads source text, so it can miss a read made through a helper and can name one that is not a
|
|
3
|
-
// query read at all
|
|
4
|
-
//
|
|
3
|
+
// query read at all. A read the key omits is still what a reader should hear about before a
|
|
4
|
+
// visitor finds it.
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive; Bun.file takes one already joined.
|
|
7
7
|
import { join } from 'node:path';
|
|
@@ -32,20 +32,15 @@ export function queryReads(source: string): readonly string[] {
|
|
|
32
32
|
const listed = (names: readonly string[]): string => names.map((name) => `'${name}'`).join(', ');
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* One line per `isr` route
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* key without it, so every visitor gets the document rendered for whichever value arrived first.
|
|
35
|
+
* One line per parameter an `isr` route's module reads and its declaration omits, no declaration
|
|
36
|
+
* being none: the page is stored under a key without it, so every visitor gets the document
|
|
37
|
+
* rendered for whichever value arrived first.
|
|
39
38
|
*/
|
|
40
39
|
export async function isrQueryAdvice(root: string): Promise<readonly string[]> {
|
|
41
40
|
const advice: string[] = [];
|
|
42
41
|
for (const route of describePages()) {
|
|
43
42
|
if (route.mode !== 'isr') continue;
|
|
44
|
-
const declared = route.revalidateQuery ??
|
|
45
|
-
if (declared === null) {
|
|
46
|
-
advice.push(msg('cli.verify.isrQueryUndeclared', { file: route.file }));
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
43
|
+
const declared = route.revalidateQuery ?? [];
|
|
49
44
|
const module = Bun.file(join(root, route.file));
|
|
50
45
|
if (!(await module.exists())) continue;
|
|
51
46
|
for (const name of queryReads(await module.text())) {
|
package/src/isr-query-guard.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The `query` object a page component is handed. For an `isr` route
|
|
2
|
-
// `revalidate.query`
|
|
1
|
+
// The `query` object a page component is handed. For an `isr` route it holds the parameters
|
|
2
|
+
// `revalidate.query` declared, none when it declares none — and, outside production, it says so
|
|
3
3
|
// once when the page reads any other one: that read is a document varying on something its stored
|
|
4
4
|
// key does not carry, which is one visitor's page answered to every visitor after.
|
|
5
5
|
|
|
@@ -22,7 +22,8 @@ export function routeQuery(
|
|
|
22
22
|
env?: ResolveEnvironmentOptions['env'],
|
|
23
23
|
): Query {
|
|
24
24
|
const query: Query = Object.fromEntries(url.searchParams);
|
|
25
|
-
const declared =
|
|
25
|
+
const declared =
|
|
26
|
+
entry.config.render === 'isr' ? (entry.config.revalidate?.query ?? []) : undefined;
|
|
26
27
|
if (declared === undefined) return query;
|
|
27
28
|
// Production pays nothing: the plain object, no trap. (`undefined`: an unreadable environment,
|
|
28
29
|
// which is not a development one either.)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The `manifest` step's drain half: a job's declared `timeout` against what a deploy gives the worker
|
|
2
|
+
// holding it. Over that, the job is cut while still inside its own limit, on every deploy that
|
|
3
|
+
// lands during it. A job with no `timeout` is not graded: a limit nobody declared is not comparable.
|
|
4
|
+
|
|
5
|
+
import type { DrainConfig } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
APP_CONFIG_FILE,
|
|
8
|
+
DRAIN_DEADLINE_DEFAULT_MS,
|
|
9
|
+
finiteCount,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
WORKER_DRAIN_DEADLINE_MAX_MS,
|
|
12
|
+
WORKER_RETIRE_DEFAULT_MS,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { cutoffTimes, registeredJobs } from '@ultimat3/jobs';
|
|
15
|
+
import { loadApp } from './app-load';
|
|
16
|
+
import type { AppLoader } from './app-permissions';
|
|
17
|
+
import type { Finding } from './output';
|
|
18
|
+
import { loadDrainConfig } from './serve-drain';
|
|
19
|
+
|
|
20
|
+
/** The two facts graded; a job handle has both. */
|
|
21
|
+
export interface BudgetedJob {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly timeoutMs: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type WorkerDrain = Pick<Partial<DrainConfig>, 'deadlineMs' | 'workerDeadlineMs' | 'workerRetireMs'>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The retire when on, for it aborts nothing; else when a SIGTERM drain CANCELS a held job, which is
|
|
30
|
+
* the cut-off's `cancelAt` (`worker-drain-cutoff.ts`), a margin before the deadline itself.
|
|
31
|
+
*/
|
|
32
|
+
export function workerBudgetMs(drain: WorkerDrain | undefined): number {
|
|
33
|
+
// Screened here, not trusted: `defineConfig` screens an app's config, but this is exported, and a
|
|
34
|
+
// NaN compares false against every timeout, which would grade every job as within budget.
|
|
35
|
+
const retire = finiteCount(
|
|
36
|
+
'the worker budget',
|
|
37
|
+
'drain.workerRetireMs',
|
|
38
|
+
drain?.workerRetireMs ?? WORKER_RETIRE_DEFAULT_MS,
|
|
39
|
+
);
|
|
40
|
+
if (retire > 0) return retire;
|
|
41
|
+
const deadline = finiteCount(
|
|
42
|
+
'the worker budget',
|
|
43
|
+
'drain.workerDeadlineMs',
|
|
44
|
+
drain?.workerDeadlineMs ?? drain?.deadlineMs ?? DRAIN_DEADLINE_DEFAULT_MS,
|
|
45
|
+
1,
|
|
46
|
+
);
|
|
47
|
+
return cutoffTimes(deadline, 0).cancelAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function outlastsFinding(job: BudgetedJob, limitMs: number, budgetMs: number): Finding {
|
|
51
|
+
return {
|
|
52
|
+
code: 'X_JOB_OUTLASTS_DRAIN',
|
|
53
|
+
cause: `${job.name} declares a timeout of ${limitMs} ms, and the worker's budget is ${budgetMs} ms, so a deploy cuts it while it is inside its own limit`,
|
|
54
|
+
// Past the ceiling no config value clears it, and a fix that cannot be applied is not a fix.
|
|
55
|
+
fix:
|
|
56
|
+
limitMs > WORKER_DRAIN_DEADLINE_MAX_MS
|
|
57
|
+
? `timeout: '24h', // on job ${job.name}: past a day no budget can hold it, so move the rest into step.run() calls, then x verify --only manifest --json`
|
|
58
|
+
: `drain: { workerRetireMs: ${limitMs} }, // in ${APP_CONFIG_FILE}, then x verify --only manifest --json`,
|
|
59
|
+
at: APP_CONFIG_FILE,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One finding per job over budget, sorted: the verdict never reads the registration order. */
|
|
64
|
+
export function jobBudgetFindings(
|
|
65
|
+
jobs: readonly BudgetedJob[],
|
|
66
|
+
drain: WorkerDrain | undefined,
|
|
67
|
+
): readonly Finding[] {
|
|
68
|
+
const budgetMs = workerBudgetMs(drain);
|
|
69
|
+
const findings: Finding[] = [];
|
|
70
|
+
for (const job of [...jobs].sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
71
|
+
if (job.timeoutMs !== undefined && job.timeoutMs > budgetMs) {
|
|
72
|
+
findings.push(outlastsFinding(job, job.timeoutMs, budgetMs));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The step's answer: the registry once the app is loaded, as `job-registration.ts` reads it, and
|
|
80
|
+
* nothing when the load or the config failed, for those are reported by what loaded them.
|
|
81
|
+
*/
|
|
82
|
+
export async function readJobBudgetFindings(
|
|
83
|
+
root: string,
|
|
84
|
+
load: AppLoader = loadApp,
|
|
85
|
+
): Promise<readonly Finding[]> {
|
|
86
|
+
if ((await load(root)).findings.length > 0) return [];
|
|
87
|
+
let drain: WorkerDrain | undefined;
|
|
88
|
+
try {
|
|
89
|
+
drain = await loadDrainConfig(root);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!isUltimateError(error) || error.code !== 'X_CONFIG_INVALID') throw error;
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return jobBudgetFindings(registeredJobs(), drain);
|
|
95
|
+
}
|
package/src/jobs-json.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
StepTrace,
|
|
14
14
|
WorkerRecord,
|
|
15
15
|
} from '@ultimat3/jobs';
|
|
16
|
+
import { consumerRow } from '@ultimat3/jobs';
|
|
16
17
|
import type { JsonValue } from './output';
|
|
17
18
|
|
|
18
19
|
function stepTraceToJson(step: StepTrace): JsonValue {
|
|
@@ -48,6 +49,12 @@ export function backfillToJson(progress: BackfillProgress): JsonValue {
|
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** A consumer run's row, spelled out field by field for the reason `backfillToJson` is. */
|
|
53
|
+
export function consumerToJson(trace: JobTrace): JsonValue {
|
|
54
|
+
const row = consumerRow(trace);
|
|
55
|
+
return row === null ? null : { key: row.key, lastBatchAt: row.lastBatchAt, lag: row.lag };
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
export function jobTraceToJson(trace: JobTrace): JsonValue {
|
|
52
59
|
return {
|
|
53
60
|
id: trace.id,
|
|
@@ -82,6 +89,9 @@ export function jobTraceToJson(trace: JobTrace): JsonValue {
|
|
|
82
89
|
// `null` for every job that is not a `backfill()` pass. Dropped, `x jobs show <id> --json`
|
|
83
90
|
// would answer "how far has it got" with silence for the one job kind that can say.
|
|
84
91
|
backfill: trace.backfill === null ? null : backfillToJson(trace.backfill),
|
|
92
|
+
// `null` for every job that is not a `consumer()` run: its key, when it last committed a batch,
|
|
93
|
+
// and its lag when the declaration has a `lag()` — read off the run's own progress.
|
|
94
|
+
consumer: consumerToJson(trace),
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -107,6 +117,7 @@ export function workerToJson(worker: WorkerRecord): JsonValue {
|
|
|
107
117
|
return {
|
|
108
118
|
id: worker.id,
|
|
109
119
|
host: worker.host,
|
|
120
|
+
build: worker.build ?? null,
|
|
110
121
|
startedAt: worker.startedAt,
|
|
111
122
|
heartbeatAt: worker.heartbeatAt,
|
|
112
123
|
queues: [...worker.queues],
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `biome check --reporter=json` read as findings, one per diagnostic. `X_LINT_FAILED` alone said
|
|
2
|
+
// `bunx biome check --write .`, and that exits "Skipped 1 suggested fixes", still red, with what was
|
|
3
|
+
// wrong in the step's `output` where a reader of `findings` never looks.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
|
+
import { commentSafe } from './comment-safe';
|
|
7
|
+
import type { ExecResult } from './exec';
|
|
8
|
+
import { execOutput } from './exec';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { quoteArg } from './shell-quote';
|
|
11
|
+
import type { StepOutcome } from './verify-step';
|
|
12
|
+
|
|
13
|
+
export const LINT_ARGV = [
|
|
14
|
+
'bunx',
|
|
15
|
+
'biome',
|
|
16
|
+
'check',
|
|
17
|
+
'--reporter=json',
|
|
18
|
+
'--max-diagnostics=none',
|
|
19
|
+
'.',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
interface Diagnostic {
|
|
23
|
+
readonly category: string;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly line: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
30
|
+
typeof value === 'object' && value !== null;
|
|
31
|
+
|
|
32
|
+
/** Only what fails the step. `info` and `warning` exit 0, so they cannot be why it is red. */
|
|
33
|
+
const diagnosticFrom = (value: unknown): Diagnostic | undefined => {
|
|
34
|
+
if (!isRecord(value) || !isRecord(value['location'])) return undefined;
|
|
35
|
+
const { severity, category, message, location } = value as {
|
|
36
|
+
severity?: unknown;
|
|
37
|
+
category?: unknown;
|
|
38
|
+
message?: unknown;
|
|
39
|
+
location: Readonly<Record<string, unknown>>;
|
|
40
|
+
};
|
|
41
|
+
if (severity !== 'error' && severity !== 'fatal') return undefined;
|
|
42
|
+
const { path, start } = location;
|
|
43
|
+
if (typeof category !== 'string' || typeof path !== 'string') return undefined;
|
|
44
|
+
const line = isRecord(start) && typeof start['line'] === 'number' ? start['line'] : 0;
|
|
45
|
+
return { category, message: typeof message === 'string' ? message : category, path, line };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The only categories `--write` always repairs. Biome's JSON does not say which lint rules carry a
|
|
50
|
+
* safe fix, and telling a rule whose fix is "unsafe" to run `--write` is the no-op this replaced, so
|
|
51
|
+
* a lint rule is an edit: true for all of them, and for the safe ones a little more work.
|
|
52
|
+
*/
|
|
53
|
+
const REPAIRED_BY_WRITE = (category: string): boolean =>
|
|
54
|
+
category === 'format' || category.startsWith('assist/');
|
|
55
|
+
|
|
56
|
+
const findingOf = ({ category, message, path, line }: Diagnostic): Finding => {
|
|
57
|
+
const place = line > 0 ? `${path}:${String(line)}` : path;
|
|
58
|
+
return {
|
|
59
|
+
code: 'X_LINT_FAILED',
|
|
60
|
+
cause: `${place} ${category}: ${message}`,
|
|
61
|
+
fix: REPAIRED_BY_WRITE(category)
|
|
62
|
+
? `bunx biome check --write ${quoteArg(path)}`
|
|
63
|
+
: `bunx biome check ${quoteArg(path)} # ${category}: edit ${commentSafe(place)}, ${commentSafe(message)}`,
|
|
64
|
+
docs: ERROR_DOCS_URL,
|
|
65
|
+
at: path,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Every failing diagnostic, or `undefined` when the output is not the report it should be. */
|
|
70
|
+
export function lintFindings(stdout: string): readonly Finding[] | undefined {
|
|
71
|
+
let report: unknown;
|
|
72
|
+
try {
|
|
73
|
+
report = JSON.parse(stdout);
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!isRecord(report) || !Array.isArray(report['diagnostics'])) return undefined;
|
|
78
|
+
return report['diagnostics'].flatMap((value) => {
|
|
79
|
+
const diagnostic = diagnosticFrom(value);
|
|
80
|
+
return diagnostic === undefined ? [] : [findingOf(diagnostic)];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A red exit with no diagnostic to read (a config biome refused, a binary that would not start) stays
|
|
86
|
+
* one finding pointing at the command, whose own output says what happened.
|
|
87
|
+
*/
|
|
88
|
+
export function lintOutcome(result: ExecResult): StepOutcome {
|
|
89
|
+
if (result.ok) return { ok: true, findings: [] };
|
|
90
|
+
const findings = lintFindings(result.stdout);
|
|
91
|
+
if (findings !== undefined && findings.length > 0) return { ok: false, findings };
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
findings: [
|
|
95
|
+
{
|
|
96
|
+
code: 'X_LINT_FAILED',
|
|
97
|
+
cause: 'biome exited red and reported no diagnostic to read',
|
|
98
|
+
fix: 'bunx biome check .',
|
|
99
|
+
docs: ERROR_DOCS_URL,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
output: execOutput(result),
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/local-cli.ts
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
// why: `realpathSync` is the whole decision — Bun ships no symlink-resolving stat of its own, and
|
|
15
15
|
// `Bun.file(path).exists()` follows a link without saying where it went.
|
|
16
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
17
17
|
// why: Bun exposes no path-join primitive; the local bin is assembled from the app root.
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import {
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { stringField } from '@ultimat3/core';
|
|
20
|
+
import { findAppRoot, versionAtLeast } from './app-root';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Set to any value to keep the CLI that was invoked. Nothing in this repository's CI needs it —
|
|
@@ -31,11 +32,34 @@ export const LOCAL_CLI_BIN = join('node_modules', '@ultimat3', 'cli', 'src', 'bi
|
|
|
31
32
|
export interface LocalCliIo {
|
|
32
33
|
exists(path: string): boolean;
|
|
33
34
|
realpath(path: string): string;
|
|
35
|
+
/** The text of a file, `package.json` here. Throwing is the same answer as an unreadable file. */
|
|
36
|
+
read(path: string): string;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
const nodeIo: LocalCliIo = {
|
|
37
40
|
exists: existsSync,
|
|
38
41
|
realpath: (path) => realpathSync(path),
|
|
42
|
+
read: (path) => readFileSync(path, 'utf8'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one command the app's own CLI must not answer when it is the older of the two: an app with no
|
|
47
|
+
* `x upgrade` yet is told `bunx @ultimat3/cli@latest upgrade`, and handing that to the app's CLI
|
|
48
|
+
* answers `X_NOT_IMPLEMENTED` from the very version it exists to move off. It is safe because of what
|
|
49
|
+
* it does not do: it loads no app, so there is no second registry — the gate is the installed CLI's.
|
|
50
|
+
*/
|
|
51
|
+
const OUTLIVES_THE_APP_CLI = 'upgrade';
|
|
52
|
+
|
|
53
|
+
const versionOfBin = (bin: string, io: LocalCliIo): string | undefined => {
|
|
54
|
+
const parsed: unknown = JSON.parse(io.read(join(dirname(dirname(bin)), 'package.json')));
|
|
55
|
+
return stringField(parsed, 'version');
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Strictly older: an equal or newer app CLI has the command too, and the usual rule stands. */
|
|
59
|
+
const appCliIsOlder = (local: string, self: string, io: LocalCliIo): boolean => {
|
|
60
|
+
const held = versionOfBin(io.realpath(local), io);
|
|
61
|
+
const running = versionOfBin(io.realpath(self), io);
|
|
62
|
+
return held !== undefined && running !== undefined && !versionAtLeast(held, running);
|
|
39
63
|
};
|
|
40
64
|
|
|
41
65
|
/**
|
|
@@ -55,6 +79,8 @@ export function resolveLocalCli(
|
|
|
55
79
|
readonly cwd: string;
|
|
56
80
|
readonly selfPath: string;
|
|
57
81
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
82
|
+
/** The word after the binary, `Bun.argv[2]` as `bin.ts` reads it for `dev`. */
|
|
83
|
+
readonly command?: string | undefined;
|
|
58
84
|
},
|
|
59
85
|
io: LocalCliIo = nodeIo,
|
|
60
86
|
): string | undefined {
|
|
@@ -64,7 +90,11 @@ export function resolveLocalCli(
|
|
|
64
90
|
const local = join(root.dir, LOCAL_CLI_BIN);
|
|
65
91
|
if (!io.exists(local)) return undefined;
|
|
66
92
|
try {
|
|
67
|
-
|
|
93
|
+
if (io.realpath(local) === io.realpath(input.selfPath)) return undefined;
|
|
94
|
+
if (input.command === OUTLIVES_THE_APP_CLI && appCliIsOlder(local, input.selfPath, io)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return local;
|
|
68
98
|
} catch {
|
|
69
99
|
return undefined;
|
|
70
100
|
}
|
package/src/loopback-host.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// to loopback) or a loopback address literal. One answer for `x dev`'s Host check and `x doctor`'s
|
|
3
3
|
// APP_URL rule, which ask the same question about two different headers.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { peerAddressClass } from '@ultimat3/core';
|
|
6
6
|
|
|
7
7
|
/** `hostname` as `URL` spells it — `[::1]` bracketed, no port. */
|
|
8
8
|
export const isLoopbackHostname = (hostname: string): boolean => {
|
|
9
9
|
const name = hostname.toLowerCase();
|
|
10
10
|
return (
|
|
11
|
-
name === 'localhost' || name.endsWith('.localhost') ||
|
|
11
|
+
name === 'localhost' || name.endsWith('.localhost') || peerAddressClass(name) === 'loopback'
|
|
12
12
|
);
|
|
13
13
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: every declared mail preview rendered in every shipped locale, as `x verify`'s
|
|
2
|
+
// `i18n` step findings — so a preview is a test too: a key a locale lacks, or a preview its own
|
|
3
|
+
// schema refuses, is red at the gate instead of in a customer's inbox.
|
|
4
|
+
|
|
5
|
+
import { mailKeyMissing, previewableMailIds, previewMail } from '@ultimat3/mail';
|
|
6
|
+
import type { Finding } from './output';
|
|
7
|
+
import { findingFrom } from './output';
|
|
8
|
+
|
|
9
|
+
export function mailPreviewFindings(locales: readonly string[]): readonly Finding[] {
|
|
10
|
+
const findings: Finding[] = [];
|
|
11
|
+
for (const id of previewableMailIds()) {
|
|
12
|
+
for (const locale of locales) {
|
|
13
|
+
try {
|
|
14
|
+
const preview = previewMail(id, { locale });
|
|
15
|
+
if (preview.misses.length > 0) {
|
|
16
|
+
findings.push(findingFrom(mailKeyMissing(id, locale, preview.misses)));
|
|
17
|
+
}
|
|
18
|
+
} catch (error) {
|
|
19
|
+
findings.push(findingFrom(error));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return findings;
|
|
24
|
+
}
|