@ultimat3/cli 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +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 +1314 -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,263 @@
|
|
|
1
|
+
// The `error-declared` guard `x new` ships: an app code is declared with `defineError`, never a
|
|
2
|
+
// handwritten `UltimateError` subclass or a literal-coded `new UltimateError`. Those forms take a
|
|
3
|
+
// title, a status and a class in three places, and nothing made the status get written — so the
|
|
4
|
+
// reference app's "post not found" answered 500, hid its cause and paged the error monitor.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'error-declared';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// error-declared: an app code is declared once, with defineError — its title, its status and its class.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Known limits, both read by nothing short of a type checker: an alias by assignment
|
|
19
|
+
// (const Base = UltimateError) and a shorthand key whose value is a literal elsewhere
|
|
20
|
+
// (const code = 'X_NOT_FOUND'; new UltimateError({ code, … })).
|
|
21
|
+
|
|
22
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
23
|
+
|
|
24
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
25
|
+
const CODE = '${CODE}';
|
|
26
|
+
|
|
27
|
+
/** Every import of core — the barrel or its page subpath: a named list (an alias) or a namespace. */
|
|
28
|
+
const NAMED_IMPORT =
|
|
29
|
+
/\\bimport\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*['"]@ultimat3\\/core(?:\\/page)?['"]/g;
|
|
30
|
+
const NAMESPACE_IMPORT =
|
|
31
|
+
/\\bimport\\s+\\*\\s+as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s*['"]@ultimat3\\/core(?:\\/page)?['"]/g;
|
|
32
|
+
const ALIAS = /^\\s*UltimateError\\s+as\\s+([A-Za-z_$][\\w$]*)\\s*$/;
|
|
33
|
+
|
|
34
|
+
/** A literal code as the object's own key — bare or quoted — read where the walk below stands. */
|
|
35
|
+
const LITERAL_CODE = /^(?:code|'code'|"code")\\s*:\\s*['"\`]/;
|
|
36
|
+
|
|
37
|
+
export interface SourceFile {
|
|
38
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly source: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
|
|
44
|
+
const blank = (text: string): string =>
|
|
45
|
+
text
|
|
46
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
47
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
48
|
+
|
|
49
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
50
|
+
|
|
51
|
+
/** The names UltimateError goes by in this file: its own, an alias, or a namespace's member. */
|
|
52
|
+
const classNames = (text: string): readonly string[] => {
|
|
53
|
+
const names = ['UltimateError'];
|
|
54
|
+
for (const match of text.matchAll(NAMED_IMPORT)) {
|
|
55
|
+
for (const part of (match[1] ?? '').split(',')) {
|
|
56
|
+
const alias = ALIAS.exec(part)?.[1];
|
|
57
|
+
if (alias !== undefined) names.push(alias);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const match of text.matchAll(NAMESPACE_IMPORT)) {
|
|
61
|
+
if (match[1] !== undefined) names.push(\`\${match[1]}.UltimateError\`);
|
|
62
|
+
}
|
|
63
|
+
return names;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** One alternation over those names, a namespace's dot allowed its whitespace. */
|
|
67
|
+
const nameSource = (names: readonly string[]): string =>
|
|
68
|
+
names.map((name) => name.replaceAll('$', '\\\\$').replaceAll('.', '\\\\s*\\\\.\\\\s*')).join('|');
|
|
69
|
+
|
|
70
|
+
/** Past the string or template opening at open. */
|
|
71
|
+
const pastQuoted = (text: string, open: number): number => {
|
|
72
|
+
let index = open + 1;
|
|
73
|
+
while (index < text.length && text[index] !== text[open]) {
|
|
74
|
+
index += text[index] === '\\\\' ? 2 : 1;
|
|
75
|
+
}
|
|
76
|
+
return index + 1;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether the object literal opening at open carries a literal code as its OWN key, or spreads one
|
|
81
|
+
* in from a nested literal. A key deeper down — meta: { code: 'X_TIMEOUT' } — is a value, not
|
|
82
|
+
* the error's code.
|
|
83
|
+
*/
|
|
84
|
+
const literalCodeIn = (text: string, open: number): boolean => {
|
|
85
|
+
let depth = 0;
|
|
86
|
+
let index = open + 1;
|
|
87
|
+
while (index < text.length) {
|
|
88
|
+
const char = text[index] ?? '';
|
|
89
|
+
// Read before a quote is skipped: a quoted key ('code': …) starts with one.
|
|
90
|
+
if (depth === 0) {
|
|
91
|
+
const rest = text.slice(index);
|
|
92
|
+
if (LITERAL_CODE.test(rest) && !/[\\w$.]/.test(text[index - 1] ?? '')) return true;
|
|
93
|
+
const spread = /^\\.\\.\\.\\s*\\{/.exec(rest);
|
|
94
|
+
if (spread !== null && literalCodeIn(text, index + spread[0].length - 1)) return true;
|
|
95
|
+
}
|
|
96
|
+
if (char === "'" || char === '"' || char === '\`') {
|
|
97
|
+
index = pastQuoted(text, index);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if ('{[('.includes(char)) depth += 1;
|
|
101
|
+
if ('}])'.includes(char)) depth -= 1;
|
|
102
|
+
if (depth < 0) return false;
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const finding = (file: SourceFile, line: number, what: string): Finding => ({
|
|
109
|
+
code: CODE,
|
|
110
|
+
cause: \`\${file.path}:\${line} \${what} — the title, the HTTP status and the class are then declared apart, and a code with no status answers 500 and hides its cause\`,
|
|
111
|
+
fix: \`rewrite it in \${file.path} as defineError({ code, title, status, cause, fix }) from @ultimat3/core (offSocket: true for a code no response carries), then: x verify\`,
|
|
112
|
+
at: file.path,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
116
|
+
export function undeclaredErrors(files: readonly SourceFile[]): readonly Finding[] {
|
|
117
|
+
const found: { readonly path: string; readonly line: number; readonly finding: Finding }[] = [];
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const text = blank(file.source);
|
|
120
|
+
const names = nameSource(classNames(text));
|
|
121
|
+
const push = (index: number, what: string): void => {
|
|
122
|
+
const line = lineOf(text, index);
|
|
123
|
+
found.push({ path: file.path, line, finding: finding(file, line, what) });
|
|
124
|
+
};
|
|
125
|
+
for (const match of text.matchAll(new RegExp(\`\\\\bextends\\\\s+(?:\${names})\\\\b\`, 'g'))) {
|
|
126
|
+
push(match.index, 'subclasses UltimateError by hand');
|
|
127
|
+
}
|
|
128
|
+
for (const match of text.matchAll(new RegExp(\`\\\\bnew\\\\s+(?:\${names})\\\\s*\\\\(\\\\s*\`, 'g'))) {
|
|
129
|
+
const at = match.index + match[0].length;
|
|
130
|
+
// Anything but an object literal hides its code from this read: a value built elsewhere,
|
|
131
|
+
// Object.assign over a literal. A re-raise is written as a literal, with code: err.code.
|
|
132
|
+
if (text[at] !== '{')
|
|
133
|
+
push(match.index, 'builds new UltimateError from a value this guard cannot read');
|
|
134
|
+
else if (literalCodeIn(text, at))
|
|
135
|
+
push(match.index, 'builds new UltimateError with a literal code');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return found
|
|
139
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line))
|
|
140
|
+
.map((entry) => entry.finding);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const guard: Guard = {
|
|
144
|
+
summary:
|
|
145
|
+
'an app error code is declared with defineError — never a handwritten UltimateError subclass',
|
|
146
|
+
async check(_root, sources) {
|
|
147
|
+
// A test may build any error it likes as INPUT. The run's ONE read of the source: every guard
|
|
148
|
+
// asking for this glob shares the walk.
|
|
149
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
150
|
+
return undeclaredErrors(
|
|
151
|
+
code
|
|
152
|
+
.filter((file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path))
|
|
153
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
const test =
|
|
160
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
161
|
+
// a green gate over the convention it was written to enforce.
|
|
162
|
+
|
|
163
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
164
|
+
import { undeclaredErrors } from './error-declared';
|
|
165
|
+
|
|
166
|
+
const file = (source: string) => [{ path: 'apps/web/app/post/errors.ts', source }];
|
|
167
|
+
|
|
168
|
+
/** Spelled from parts, so a grep of the app for a handwritten subclass finds only real ones. */
|
|
169
|
+
const EXTENDS = ['extends', 'UltimateError'].join(' ');
|
|
170
|
+
|
|
171
|
+
unitTest('a handwritten subclass is refused, and the finding names the line', () => {
|
|
172
|
+
const findings = undeclaredErrors(file(\`const x = 1;\\nexport class PostNotFound \${EXTENDS} {}\`));
|
|
173
|
+
expect(findings).toHaveLength(1);
|
|
174
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
175
|
+
expect(findings[0]?.cause).toContain(':2');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
unitTest('a literal code handed to new UltimateError is refused', () => {
|
|
179
|
+
expect(
|
|
180
|
+
undeclaredErrors(
|
|
181
|
+
file("throw new UltimateError({ code: 'X_NOT_FOUND', cause: 'c', fix: 'x' });"),
|
|
182
|
+
),
|
|
183
|
+
).toHaveLength(1);
|
|
184
|
+
expect(
|
|
185
|
+
undeclaredErrors(file("new UltimateError({ cause: 'c', code: \\"X_NOT_FOUND\\", fix: 'x' });")),
|
|
186
|
+
).toHaveLength(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
unitTest('a re-raise passing err.code is not a declaration', () => {
|
|
190
|
+
expect(
|
|
191
|
+
undeclaredErrors(
|
|
192
|
+
file("new UltimateError({ code: err.code, cause: err.cause, fix: 'x verify' });"),
|
|
193
|
+
),
|
|
194
|
+
).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
unitTest('defineError is what the rule asks for, and a comment is not code', () => {
|
|
198
|
+
expect(
|
|
199
|
+
undeclaredErrors(
|
|
200
|
+
file(
|
|
201
|
+
"export const Gone = defineError({ code: 'X_FORBIDDEN', title: 't', status: 410, cause: 'c', fix: 'x' });",
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
).toEqual([]);
|
|
205
|
+
expect(undeclaredErrors(file(\`// class Old \${EXTENDS} {}\`))).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
unitTest('an aliased or namespaced UltimateError is the same class', () => {
|
|
209
|
+
const aliased = "import { UltimateError as Base } from '@ultimat3/core';\\n";
|
|
210
|
+
expect(
|
|
211
|
+
undeclaredErrors(
|
|
212
|
+
file(\`\${aliased}export class A \${EXTENDS.replace('UltimateError', 'Base')} {}\`),
|
|
213
|
+
),
|
|
214
|
+
).toHaveLength(1);
|
|
215
|
+
const namespaced = "import * as core from '@ultimat3/core';\\n";
|
|
216
|
+
expect(
|
|
217
|
+
undeclaredErrors(
|
|
218
|
+
file(
|
|
219
|
+
\`\${namespaced}throw new core.UltimateError({ code: 'X_NOT_FOUND', cause: 'c', fix: 'x' });\`,
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
unitTest(
|
|
226
|
+
'a code spread in, or behind a value, is refused; one nested in meta is not the code',
|
|
227
|
+
() => {
|
|
228
|
+
expect(
|
|
229
|
+
undeclaredErrors(
|
|
230
|
+
file("throw new UltimateError({ ...{ code: 'X_NOT_FOUND' }, cause: 'c', fix: 'x' });"),
|
|
231
|
+
),
|
|
232
|
+
).toHaveLength(1);
|
|
233
|
+
expect(undeclaredErrors(file('throw new UltimateError(init);'))).toHaveLength(1);
|
|
234
|
+
expect(
|
|
235
|
+
undeclaredErrors(
|
|
236
|
+
file(
|
|
237
|
+
"new UltimateError({ code: err.code, cause: 'c', fix: 'x', meta: { code: 'X_TIMEOUT' } });",
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
).toEqual([]);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
unitTest('an alias from the page subpath and a quoted code key are the same rule', () => {
|
|
245
|
+
const paged = "import { UltimateError as PageError } from '@ultimat3/core/page';\\n";
|
|
246
|
+
expect(
|
|
247
|
+
undeclaredErrors(
|
|
248
|
+
file(\`\${paged}throw new PageError({ code: 'X_NOT_FOUND', cause: 'c', fix: 'x' });\`),
|
|
249
|
+
),
|
|
250
|
+
).toHaveLength(1);
|
|
251
|
+
expect(
|
|
252
|
+
undeclaredErrors(
|
|
253
|
+
file("throw new UltimateError({ 'code': 'X_INTERNAL', cause: 'c', fix: 'x' });"),
|
|
254
|
+
),
|
|
255
|
+
).toHaveLength(1);
|
|
256
|
+
});
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
/** `guards/error-declared.ts` and its test. No index, no registry — the directory registers it. */
|
|
260
|
+
export const errorDeclaredGuardFiles = (): readonly GeneratedFile[] => [
|
|
261
|
+
{ path: 'guards/error-declared.ts', contents: source() },
|
|
262
|
+
{ path: 'guards/error-declared.test.ts', contents: test() },
|
|
263
|
+
];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The `native-confirm` guard `x new` ships: no app code asks a destructive question through
|
|
2
|
+
// `window.confirm(`. The browser's box holds focus on OK, so a stray Enter confirms; it cannot be
|
|
3
|
+
// styled, translated or refused, and it blocks the page. `ConfirmDialog` from `@ultimat3/ui` is the
|
|
4
|
+
// one way. A `// why:` on the call's line, or the line above it, admits a call that is not that.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'native-confirm';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// native-confirm: a destructive question is a ConfirmDialog, never window.confirm(.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Why: the browser's confirm box focuses OK, so the Enter that opened it confirms it; it cannot be
|
|
19
|
+
// translated, styled or refused, and it blocks the page while it is up. \`ConfirmDialog\` from
|
|
20
|
+
// \`@ultimat3/ui\` focuses Cancel, posts natively with scripting off and stays open on a refusal.
|
|
21
|
+
// Read: a bare, \`window.\`, \`self.\` or \`globalThis.\` \`confirm(\` in a \`*.island.tsx\` or anywhere
|
|
22
|
+
// under \`apps/web/\`. Admitted: a \`// why:\` on the call's line or the line above it.
|
|
23
|
+
|
|
24
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
25
|
+
|
|
26
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
27
|
+
const CODE = '${CODE}';
|
|
28
|
+
|
|
29
|
+
export interface SourceFile {
|
|
30
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly source: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CALL = /(?<![\\w$.])(?<!function\\s+)(?:(?:globalThis|window|self)\\s*\\.\\s*)?confirm\\s*\\(/g;
|
|
36
|
+
const WHY = /\\/\\/\\s*why:/;
|
|
37
|
+
|
|
38
|
+
/** Comments and string bodies blanked IN PLACE, so a line number still points at the source. */
|
|
39
|
+
const blank = (text: string): string =>
|
|
40
|
+
text
|
|
41
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
42
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length))
|
|
43
|
+
.replaceAll(/'(?:[^'\\\\\\n]|\\\\.)*'|"(?:[^"\\\\\\n]|\\\\.)*"|\`(?:[^\`\\\\]|\\\\.)*\`/g, (match) =>
|
|
44
|
+
match.replaceAll(/[^\\n]/g, ' '),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
48
|
+
export function nativeConfirms(files: readonly SourceFile[]): readonly Finding[] {
|
|
49
|
+
const findings: Finding[] = [];
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const lines = file.source.split('\\n');
|
|
52
|
+
const text = blank(file.source);
|
|
53
|
+
for (const match of text.matchAll(CALL)) {
|
|
54
|
+
const line = text.slice(0, match.index).split('\\n').length;
|
|
55
|
+
if (WHY.test(lines[line - 1] ?? '') || WHY.test(lines[line - 2] ?? '')) continue;
|
|
56
|
+
const at = \`\${file.path}:\${line}\`;
|
|
57
|
+
findings.push({
|
|
58
|
+
code: CODE,
|
|
59
|
+
cause: \`\${at} asks with confirm(), whose OK holds focus — a stray Enter confirms — and which cannot be translated or refused\`,
|
|
60
|
+
fix: \`replace the confirm() at \${at} with <ConfirmDialog> from '@ultimat3/ui' (action= for a native post, onConfirm= in an island) — or add a // why: comment on that line naming why it is not a destructive question — then: x verify\`,
|
|
61
|
+
at,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The files the rule reads: islands anywhere, and all of \`apps/web/\`; never a test. */
|
|
69
|
+
export const readsFile = (path: string): boolean =>
|
|
70
|
+
!/\\.test\\.tsx?$/.test(path) && (path.endsWith('.island.tsx') || path.startsWith('apps/web/'));
|
|
71
|
+
|
|
72
|
+
export const guard: Guard = {
|
|
73
|
+
summary: 'a destructive question is a ConfirmDialog, never window.confirm()',
|
|
74
|
+
async check(_root, sources) {
|
|
75
|
+
// The run's ONE read of the source: every guard asking for this glob shares the walk.
|
|
76
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
77
|
+
return nativeConfirms(
|
|
78
|
+
code
|
|
79
|
+
.filter((file) => readsFile(file.path))
|
|
80
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const test =
|
|
87
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
88
|
+
// a green gate over the convention it was written to enforce.
|
|
89
|
+
|
|
90
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
91
|
+
import { nativeConfirms, readsFile } from './native-confirm';
|
|
92
|
+
|
|
93
|
+
const file = (source: string) => [{ path: 'apps/web/app/posts/delete.island.tsx', source }];
|
|
94
|
+
|
|
95
|
+
unitTest('a bare confirm( is refused, and the finding names the line', () => {
|
|
96
|
+
const findings = nativeConfirms(file("const a = 1;\\nif (confirm('Delete?')) remove();"));
|
|
97
|
+
expect(findings).toHaveLength(1);
|
|
98
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
99
|
+
expect(findings[0]?.at).toBe('apps/web/app/posts/delete.island.tsx:2');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
unitTest('window., self. and globalThis. are the same rule', () => {
|
|
103
|
+
expect(nativeConfirms(file("window.confirm('Delete?');"))).toHaveLength(1);
|
|
104
|
+
expect(nativeConfirms(file("self.confirm('Delete?');"))).toHaveLength(1);
|
|
105
|
+
expect(nativeConfirms(file("globalThis.confirm('Delete?');"))).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
unitTest('a why: on the line or the line above admits it', () => {
|
|
109
|
+
expect(nativeConfirms(file("confirm('Leave?'); // why: an unload prompt"))).toEqual([]);
|
|
110
|
+
expect(nativeConfirms(file("// why: a dev-only reset\\nconfirm('Reset?');"))).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
unitTest('a comment, a string, a method and a definition are not calls', () => {
|
|
114
|
+
expect(nativeConfirms(file("// confirm('Delete?');"))).toEqual([]);
|
|
115
|
+
expect(nativeConfirms(file("const s = 'confirm(x)';"))).toEqual([]);
|
|
116
|
+
expect(nativeConfirms(file('dialog.confirm(input);'))).toEqual([]);
|
|
117
|
+
expect(nativeConfirms(file('function confirm(input) { return input; }'))).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
unitTest('islands and apps/web are read; tests and other packages are not', () => {
|
|
121
|
+
expect(readsFile('apps/web/app/posts/page.tsx')).toBe(true);
|
|
122
|
+
expect(readsFile('packages/ui/src/delete.island.tsx')).toBe(true);
|
|
123
|
+
expect(readsFile('apps/web/app/posts/page.test.ts')).toBe(false);
|
|
124
|
+
expect(readsFile('packages/domain/src/rules.ts')).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
/** `guards/native-confirm.ts` and its test. No index, no registry — the directory registers it. */
|
|
129
|
+
export const nativeConfirmGuardFiles = (): readonly GeneratedFile[] => [
|
|
130
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
131
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
132
|
+
];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The `raw-fetch` guard `x new` ships: a server `fetch(` carries a signal in its own options, or is
|
|
2
|
+
// `outbound()` from `@ultimat3/core/outbound`. A fetch with no signal outlives the request that made
|
|
3
|
+
// it — a stalled vendor holds the handler, the drain and a socket for as long as it likes. With a
|
|
4
|
+
// signal it stays legal: the guard cannot prove the other properties, and outbound() is the way.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'raw-fetch';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// raw-fetch: a server fetch( carries a signal in its own options, or is outbound().
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Read: a bare or \`globalThis.\` \`fetch(\` call. Not read: an injected \`deps.fetch(\`, a method
|
|
19
|
+
// DEFINITION (\`fetch(request) {\`, Bun.serve's handler), a test, or an island (a browser has no
|
|
20
|
+
// request to outlive). Options passed as a variable read as unsignalled: spread them and add the key.
|
|
21
|
+
|
|
22
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
23
|
+
|
|
24
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
25
|
+
const CODE = '${CODE}';
|
|
26
|
+
|
|
27
|
+
export interface SourceFile {
|
|
28
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly source: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CALL = /(?<![\\w$.])(?:globalThis\\s*\\.\\s*)?fetch\\s*\\(/g;
|
|
34
|
+
const SIGNAL = /(?<![\\w$.])signal\\s*[:,}]/g;
|
|
35
|
+
|
|
36
|
+
/** Comments and string bodies blanked IN PLACE, so a line number still points at the source. */
|
|
37
|
+
const blank = (text: string): string =>
|
|
38
|
+
text
|
|
39
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
40
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length))
|
|
41
|
+
.replaceAll(/'(?:[^'\\\\\\n]|\\\\.)*'|"(?:[^"\\\\\\n]|\\\\.)*"|\`(?:[^\`\\\\]|\\\\.)*\`/g, (match) =>
|
|
42
|
+
match.replaceAll(/[^\\n]/g, ' '),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
46
|
+
|
|
47
|
+
/** The index of the parenthesis closing the one at \`open\`, or the end of the text. */
|
|
48
|
+
const closeOf = (text: string, open: number): number => {
|
|
49
|
+
let depth = 0;
|
|
50
|
+
for (let index = open; index < text.length; index += 1) {
|
|
51
|
+
if (text[index] === '(') depth += 1;
|
|
52
|
+
if (text[index] === ')' && --depth === 0) return index;
|
|
53
|
+
}
|
|
54
|
+
return text.length;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Whether \`signal\` is a key of an object at the arguments' TOP level — never \`{ env: { signal } }\`. */
|
|
58
|
+
const hasSignal = (args: string): boolean => {
|
|
59
|
+
for (const match of args.matchAll(SIGNAL)) {
|
|
60
|
+
const opened: string[] = [];
|
|
61
|
+
for (const char of args.slice(0, match.index)) {
|
|
62
|
+
if (char === '{' || char === '[' || char === '(') opened.push(char);
|
|
63
|
+
else if (char === '}' || char === ']' || char === ')') opened.pop();
|
|
64
|
+
}
|
|
65
|
+
if (opened.length === 1 && opened[0] === '{') return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
71
|
+
export function rawFetches(files: readonly SourceFile[]): readonly Finding[] {
|
|
72
|
+
const findings: Finding[] = [];
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const text = blank(file.source);
|
|
75
|
+
for (const match of text.matchAll(CALL)) {
|
|
76
|
+
const open = match.index + match[0].length - 1;
|
|
77
|
+
const close = closeOf(text, open);
|
|
78
|
+
const after = text.slice(close + 1).trimStart();
|
|
79
|
+
// A definition, not a call: a method body or its return type follows the parameter list.
|
|
80
|
+
if (after.startsWith('{') || after.startsWith(':')) continue;
|
|
81
|
+
if (/\\bfunction\\s*\\*?\\s*$/.test(text.slice(0, match.index))) continue;
|
|
82
|
+
if (hasSignal(text.slice(open + 1, close))) continue;
|
|
83
|
+
const at = \`\${file.path}:\${lineOf(text, match.index)}\`;
|
|
84
|
+
findings.push({
|
|
85
|
+
code: CODE,
|
|
86
|
+
cause: \`\${at} calls fetch with no signal in its own options, so the call outlives the request that made it: a stalled vendor holds the handler, the drain and a socket\`,
|
|
87
|
+
fix: \`call outbound({ url, vendor, dial: 'public', deadlineMs: 10_000 }) from @ultimat3/core/outbound in place of the fetch at \${at}, or pass { signal: ctx.signal } in its options — then: x verify\`,
|
|
88
|
+
at,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const guard: Guard = {
|
|
96
|
+
summary:
|
|
97
|
+
'a server fetch carries a signal, or is outbound() — never a call that outlives its request',
|
|
98
|
+
async check(_root, sources) {
|
|
99
|
+
// The run's ONE read of the source: every guard asking for this glob shares the walk.
|
|
100
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
101
|
+
return rawFetches(
|
|
102
|
+
code
|
|
103
|
+
.filter(
|
|
104
|
+
(file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path) && !file.path.endsWith('.island.tsx'),
|
|
105
|
+
)
|
|
106
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const test =
|
|
113
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
114
|
+
// a green gate over the convention it was written to enforce.
|
|
115
|
+
|
|
116
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
117
|
+
import { rawFetches } from './raw-fetch';
|
|
118
|
+
|
|
119
|
+
const file = (source: string) => [{ path: 'apps/web/app/post/repo.ts', source }];
|
|
120
|
+
|
|
121
|
+
unitTest('a bare fetch is refused, and the finding names the line', () => {
|
|
122
|
+
const findings = rawFetches(file('const a = 1;\\nconst r = await fetch(url);'));
|
|
123
|
+
expect(findings).toHaveLength(1);
|
|
124
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
125
|
+
expect(findings[0]?.at).toBe('apps/web/app/post/repo.ts:2');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
unitTest('globalThis.fetch and a signal nested in another key are the same rule', () => {
|
|
129
|
+
expect(rawFetches(file('await globalThis.fetch(url, { method: "POST" });'))).toHaveLength(1);
|
|
130
|
+
expect(rawFetches(file('await fetch(url, { env: { signal } });'))).toHaveLength(1);
|
|
131
|
+
expect(rawFetches(file('await fetch(url, init);'))).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
unitTest('a signal in its own options is what the rule asks for', () => {
|
|
135
|
+
expect(rawFetches(file('await fetch(url, { signal });'))).toEqual([]);
|
|
136
|
+
expect(rawFetches(file('await fetch(url, { method: "GET", signal: ctx.signal });'))).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
unitTest('an injected fetch, a handler definition and a comment are not calls', () => {
|
|
140
|
+
expect(rawFetches(file('await deps.fetch(url);'))).toEqual([]);
|
|
141
|
+
expect(rawFetches(file('Bun.serve({ fetch(request) { return new Response(); } });'))).toEqual([]);
|
|
142
|
+
expect(rawFetches(file('// await fetch(url);'))).toEqual([]);
|
|
143
|
+
expect(rawFetches(file("const s = 'fetch(url)';"))).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
/** `guards/raw-fetch.ts` and its test. No index, no registry — the directory registers it. */
|
|
148
|
+
export const rawFetchGuardFiles = (): readonly GeneratedFile[] => [
|
|
149
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
150
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
151
|
+
];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The `raw-interval` guard `x new` ships: an island polls with `visibleInterval()` from
|
|
2
|
+
// `@ultimat3/core/page`, never a bare `setInterval(`. A raw interval keeps asking from a hidden tab
|
|
3
|
+
// and, past the session, asks for a 401 forever — five adopters hand-rolled the gate this replaces.
|
|
4
|
+
// A `// why:` on the call's line, or the line above it, admits a tick that is not a read.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'raw-interval';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// raw-interval: an island polls with visibleInterval(), never a bare setInterval(.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Why: a raw interval keeps asking from a background tab, and from a tab past its session it asks
|
|
19
|
+
// for a 401 forever. \`visibleInterval(run, ms)\` from \`@ultimat3/core/page\` makes no call while the
|
|
20
|
+
// tab is hidden, one when it is shown, and stops at the first lost session. Read: a bare,
|
|
21
|
+
// \`window.\`, \`self.\` or \`globalThis.\` \`setInterval(\` in a \`*.island.tsx\`. Admitted: a \`// why:\` on
|
|
22
|
+
// the call's line or the line above it — an animation tick is not a read.
|
|
23
|
+
|
|
24
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
25
|
+
|
|
26
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
27
|
+
const CODE = '${CODE}';
|
|
28
|
+
|
|
29
|
+
export interface SourceFile {
|
|
30
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly source: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CALL = /(?<![\\w$.])(?:(?:globalThis|window|self)\\s*\\.\\s*)?setInterval\\s*\\(/g;
|
|
36
|
+
const WHY = /\\/\\/\\s*why:/;
|
|
37
|
+
|
|
38
|
+
/** Comments and string bodies blanked IN PLACE, so a line number still points at the source. */
|
|
39
|
+
const blank = (text: string): string =>
|
|
40
|
+
text
|
|
41
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
42
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length))
|
|
43
|
+
.replaceAll(/'(?:[^'\\\\\\n]|\\\\.)*'|"(?:[^"\\\\\\n]|\\\\.)*"|\`(?:[^\`\\\\]|\\\\.)*\`/g, (match) =>
|
|
44
|
+
match.replaceAll(/[^\\n]/g, ' '),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
48
|
+
export function rawIntervals(files: readonly SourceFile[]): readonly Finding[] {
|
|
49
|
+
const findings: Finding[] = [];
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const lines = file.source.split('\\n');
|
|
52
|
+
const text = blank(file.source);
|
|
53
|
+
for (const match of text.matchAll(CALL)) {
|
|
54
|
+
const line = text.slice(0, match.index).split('\\n').length;
|
|
55
|
+
if (WHY.test(lines[line - 1] ?? '') || WHY.test(lines[line - 2] ?? '')) continue;
|
|
56
|
+
const at = \`\${file.path}:\${line}\`;
|
|
57
|
+
findings.push({
|
|
58
|
+
code: CODE,
|
|
59
|
+
cause: \`\${at} polls with setInterval, which keeps asking from a hidden tab and asks for a 401 forever once the session is gone\`,
|
|
60
|
+
fix: \`import { visibleInterval } from '@ultimat3/core/page' and call visibleInterval(run, ms) at \${at}, disposing it on cleanup — or add a // why: comment on that line naming why this interval is not a read — then: x verify\`,
|
|
61
|
+
at,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const guard: Guard = {
|
|
69
|
+
summary: 'an island polls with visibleInterval(), never a bare setInterval',
|
|
70
|
+
async check(_root, sources) {
|
|
71
|
+
// The run's ONE read of the source: every guard asking for this glob shares the walk.
|
|
72
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
73
|
+
return rawIntervals(
|
|
74
|
+
code
|
|
75
|
+
.filter((file) => file.path.endsWith('.island.tsx'))
|
|
76
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const test =
|
|
83
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
84
|
+
// a green gate over the convention it was written to enforce.
|
|
85
|
+
|
|
86
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
87
|
+
import { rawIntervals } from './raw-interval';
|
|
88
|
+
|
|
89
|
+
const file = (source: string) => [{ path: 'apps/web/app/feed/ticker.island.tsx', source }];
|
|
90
|
+
|
|
91
|
+
unitTest('a bare setInterval is refused, and the finding names the line', () => {
|
|
92
|
+
const findings = rawIntervals(file('const a = 1;\\nconst id = setInterval(poll, 5000);'));
|
|
93
|
+
expect(findings).toHaveLength(1);
|
|
94
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
95
|
+
expect(findings[0]?.at).toBe('apps/web/app/feed/ticker.island.tsx:2');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
unitTest('window., self. and globalThis. are the same rule', () => {
|
|
99
|
+
expect(rawIntervals(file('window.setInterval(poll, 5000);'))).toHaveLength(1);
|
|
100
|
+
expect(rawIntervals(file('self.setInterval(poll, 5000);'))).toHaveLength(1);
|
|
101
|
+
expect(rawIntervals(file('globalThis.setInterval(poll, 5000);'))).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
unitTest('a why: on the line or the line above admits it', () => {
|
|
105
|
+
expect(rawIntervals(file('setInterval(tick, 16); // why: a frame, not a read'))).toEqual([]);
|
|
106
|
+
expect(rawIntervals(file('// why: a clock, not a read\\nsetInterval(tick, 1000);'))).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
unitTest('visibleInterval, another object, a comment and a string are not calls', () => {
|
|
110
|
+
expect(rawIntervals(file('visibleInterval(poll, 5000);'))).toEqual([]);
|
|
111
|
+
expect(rawIntervals(file('timers.setInterval(poll, 5000);'))).toEqual([]);
|
|
112
|
+
expect(rawIntervals(file('// setInterval(poll, 5000);'))).toEqual([]);
|
|
113
|
+
expect(rawIntervals(file("const s = 'setInterval(poll)';"))).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
/** `guards/raw-interval.ts` and its test. No index, no registry — the directory registers it. */
|
|
118
|
+
export const rawIntervalGuardFiles = (): readonly GeneratedFile[] => [
|
|
119
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
120
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
121
|
+
];
|