@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
|
|
8
8
|
const feature = (app: NameSet): string => `---
|
|
9
|
-
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bun run
|
|
9
|
+
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bun run verify\`.
|
|
10
10
|
argument-hint: <what you want built or fixed, plain language>
|
|
11
11
|
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
12
12
|
---
|
|
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
|
16
16
|
You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
|
|
17
17
|
anything — it is the short form of every rule below, and it wins where the two disagree.
|
|
18
18
|
|
|
19
|
-
**Done means \`bun run
|
|
19
|
+
**Done means \`bun run verify\` green.** A passing unit test is not done. A working \`bunx x dev\` is not done.
|
|
20
20
|
Report what you actually ran, never what you assume passed.
|
|
21
21
|
|
|
22
22
|
## Request
|
|
@@ -49,15 +49,15 @@ obvious, hand the request to the \`shape\` subagent — that pass is the whole r
|
|
|
49
49
|
## 2. Generate it — do not hand-write it
|
|
50
50
|
|
|
51
51
|
\`\`\`sh
|
|
52
|
-
x g <kind> <name> --feature <slice> # x g --help is the only list of kinds
|
|
52
|
+
bunx x g <kind> <name> --feature <slice> # bunx x g --help is the only list of kinds
|
|
53
53
|
\`\`\`
|
|
54
54
|
|
|
55
55
|
The generator writes the source, its test and its i18n keys as one unit and registers it. A
|
|
56
|
-
hand-written primitive compiles and then goes missing from \`x routes\`, \`x actions\`, \`x queries\`,
|
|
57
|
-
\`x jobs\`, \`x tasks\`, \`x policy list\` and \`x.manifest.json\` — every surface that is supposed to
|
|
56
|
+
hand-written primitive compiles and then goes missing from \`bunx x routes\`, \`bunx x actions\`, \`bunx x queries\`,
|
|
57
|
+
\`bunx x jobs\`, \`bunx x tasks\`, \`bunx x policy list\` and \`x.manifest.json\` — every surface that is supposed to
|
|
58
58
|
project it. Edit what the generator wrote; never reproduce it.
|
|
59
59
|
|
|
60
|
-
Schema changes are generated too: edit \`entity.ts\`, then \`x db gen "what changed"\`. Never hand-write
|
|
60
|
+
Schema changes are generated too: edit \`entity.ts\`, then \`bunx x db gen "what changed"\`. Never hand-write
|
|
61
61
|
SQL into \`packages/db/migrations/\`.
|
|
62
62
|
|
|
63
63
|
## 3. Wire it inside the boundaries
|
|
@@ -72,23 +72,21 @@ SQL into \`packages/db/migrations/\`.
|
|
|
72
72
|
| routes | call actions and queries, never a repo | policy skipped |
|
|
73
73
|
|
|
74
74
|
Route files: \`page.tsx\` under \`site/\`/\`app/\`, and none under \`api/\`. **The directory is the URL** —
|
|
75
|
-
the filename never is. One interactive control on a 0kb page is an island: \`x g island <name> --at <dir>\`.
|
|
75
|
+
the filename never is. One interactive control on a 0kb page is an island: \`bunx x g island <name> --at <dir>\`.
|
|
76
76
|
|
|
77
77
|
## 4. Gate it
|
|
78
78
|
|
|
79
79
|
\`\`\`sh
|
|
80
|
-
bun run
|
|
81
|
-
bun run
|
|
80
|
+
bun run verify # the gate. green = shippable
|
|
81
|
+
bun run verify --json # the same, machine-readable
|
|
82
82
|
\`\`\`
|
|
83
83
|
|
|
84
|
-
\`bun run
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
|
|
88
|
-
\`bun run setup\` and then \`bun run check\`, nothing else.
|
|
84
|
+
\`bun run verify\` is \`bunx x verify\`, the whole gate: its \`budgets\` step builds what it weighs when
|
|
85
|
+
\`.x/build-stats.json\` is missing or older than the source. CI runs this same script —
|
|
86
|
+
\`.github/workflows/ci.yml\` is \`bun run setup\` and then \`bun run verify\`, nothing else.
|
|
89
87
|
|
|
90
88
|
Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
|
|
91
|
-
before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
|
|
89
|
+
before improvising**, and \`bunx x errors explain <CODE>\` expands any code it names. Never narrow the gate
|
|
92
90
|
to make it pass: there is no \`--only\` and no \`--skip\`, on purpose, and disabling a lint rule or
|
|
93
91
|
loosening a compiler flag is the same move wearing a different hat.
|
|
94
92
|
|
|
@@ -98,15 +96,16 @@ While you iterate, narrow the *feedback*, not the gate:
|
|
|
98
96
|
|---|---|
|
|
99
97
|
| one test file | \`bun test <path>/<file>.test.ts\` |
|
|
100
98
|
| one test by name | \`bun test -t '<name>'\` |
|
|
101
|
-
| a whole type | \`x test unit\` · \`x test contract\` · \`x test e2e\` |
|
|
99
|
+
| a whole type | \`bunx x test unit\` · \`bunx x test contract\` · \`bunx x test e2e\` |
|
|
102
100
|
| lint the files you touched | \`bunx biome check --write <paths>\` |
|
|
103
101
|
| types, once, when otherwise done | \`bun run typecheck\` |
|
|
104
|
-
| a broken environment | \`x doctor\` |
|
|
102
|
+
| a broken environment | \`bunx x doctor\` |
|
|
105
103
|
|
|
106
104
|
## 5. Hard rules
|
|
107
105
|
|
|
108
|
-
Never a bare \`throw new Error\` —
|
|
109
|
-
|
|
106
|
+
Never a bare \`throw new Error\` — declare each code once with \`defineError\` from \`@ultimat3/core\`:
|
|
107
|
+
a stable code, a title, a status, a cause and a runnable \`fix:\`.
|
|
108
|
+
No \`any\`; use \`unknown\` and parse. Named exports only. \`import type\` for types.
|
|
110
109
|
Tests next to the source as \`<file>.test.ts\`, failure case first — a test that cannot fail is not a
|
|
111
110
|
test. One file, one job. Every user-facing string through \`t()\`. Semantic tokens, never a raw
|
|
112
111
|
colour. Every date formatted with an explicit IANA time zone. Money is integer minor units plus an
|
|
@@ -116,9 +115,9 @@ ISO code, never a float. Bun only.
|
|
|
116
115
|
|
|
117
116
|
\`\`\`
|
|
118
117
|
Primitive: <which of the eight> Slice: <dir>
|
|
119
|
-
Generated: <
|
|
118
|
+
Generated: <every \`bunx x g\` command you ran>
|
|
120
119
|
Changed: <files>
|
|
121
|
-
Gate: bun run
|
|
120
|
+
Gate: bun run verify ✓ | ✗ <failing steps>
|
|
122
121
|
Deferred: <what you did not do, and why> [never omit this line]
|
|
123
122
|
\`\`\`
|
|
124
123
|
`;
|
|
@@ -166,13 +165,13 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
166
165
|
- \`path:line\` — what changes, and why.
|
|
167
166
|
|
|
168
167
|
## Steps
|
|
169
|
-
1. Ordered, concrete. Name the \`x g\` invocation where one applies. Point at code, do not paste it.
|
|
168
|
+
1. Ordered, concrete. Name the \`bunx x g\` invocation where one applies. Point at code, do not paste it.
|
|
170
169
|
|
|
171
170
|
## Tests
|
|
172
171
|
- What to add, next to the source as \`<file>.test.ts\`. Command to run it.
|
|
173
172
|
|
|
174
173
|
## Done when
|
|
175
|
-
- Acceptance criteria, ending in \`bun run
|
|
174
|
+
- Acceptance criteria, ending in \`bun run verify\` green.
|
|
176
175
|
|
|
177
176
|
## Risks
|
|
178
177
|
- Anything the executor must decide, and every claim in the ask the code disproves.
|
|
@@ -185,7 +184,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
185
184
|
- No checkboxes. The plan is a map, not a tracker.
|
|
186
185
|
- The plan must obey the app's own rules — one way to do each thing, generators over hand-written
|
|
187
186
|
files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
|
|
188
|
-
every new failure, and \`bun run
|
|
187
|
+
every new failure, and \`bun run verify\` green as the last line of *Done when*.
|
|
189
188
|
|
|
190
189
|
## Output
|
|
191
190
|
|
|
@@ -202,18 +201,16 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
|
202
201
|
|
|
203
202
|
# /verify
|
|
204
203
|
|
|
205
|
-
Run \`bun run
|
|
204
|
+
Run \`bun run verify\`.
|
|
206
205
|
|
|
207
|
-
It is \`
|
|
208
|
-
|
|
209
|
-
the \`budgets\` step measures \`.x/build-stats.json\`, which only the build writes, so a bare
|
|
210
|
-
\`x verify\` reports X_BUDGET_UNMEASURED on a tree with nothing wrong with it.
|
|
206
|
+
It is \`bunx x verify\`, and it is THE gate — the one CI runs (\`.github/workflows/ci.yml\`) and the
|
|
207
|
+
one \`README.md\` names. Its \`budgets\` step builds what it weighs, so nothing runs before it.
|
|
211
208
|
|
|
212
209
|
Green: say so and stop.
|
|
213
210
|
|
|
214
211
|
Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
|
|
215
|
-
executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
|
|
216
|
-
when the cause is not enough. \`bun run
|
|
212
|
+
executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`bunx x errors explain <CODE>\`
|
|
213
|
+
when the cause is not enough. \`bun run verify --json\` gives the same steps machine-readably; \`bunx x doctor\`
|
|
217
214
|
covers the case where the environment, not the code, is what is broken.
|
|
218
215
|
|
|
219
216
|
Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
|
|
@@ -9,6 +9,7 @@ import { claudeCommandFiles } from './scaffold-claude-commands';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Read-only commands and the two write commands whose blast radius is a file the gate checks.
|
|
12
|
+
* Spelled `bunx x`, the form that runs with no global `x` and the one the generators print.
|
|
12
13
|
* Deliberately absent: `x db reset`, `x db backfill --write`, `x secrets`, `x deploy` — each is
|
|
13
14
|
* destructive or reaches production, and an allowlist that covers them is a prompt nobody reads.
|
|
14
15
|
*
|
|
@@ -23,26 +24,28 @@ const settings = (): string => `{
|
|
|
23
24
|
"Bash(bun test:*)",
|
|
24
25
|
"Bash(bun run typecheck)",
|
|
25
26
|
"Bash(bun run lint)",
|
|
27
|
+
"Bash(bun run verify:*)",
|
|
26
28
|
"Bash(bunx biome check:*)",
|
|
27
|
-
"Bash(x verify:*)",
|
|
28
|
-
"Bash(x test:*)",
|
|
29
|
-
"Bash(x doctor:*)",
|
|
30
|
-
"Bash(x g:*)",
|
|
31
|
-
"Bash(x routes:*)",
|
|
32
|
-
"Bash(x actions:*)",
|
|
33
|
-
"Bash(x queries:*)",
|
|
34
|
-
"Bash(x entities:*)",
|
|
35
|
-
"Bash(x jobs list:*)",
|
|
36
|
-
"Bash(x jobs show:*)",
|
|
37
|
-
"Bash(x tasks:*)",
|
|
38
|
-
"Bash(x policy:*)",
|
|
39
|
-
"Bash(x i18n check:*)",
|
|
40
|
-
"Bash(x errors:*)",
|
|
41
|
-
"Bash(x
|
|
42
|
-
"Bash(x
|
|
43
|
-
"Bash(x
|
|
44
|
-
"Bash(x db
|
|
45
|
-
"Bash(x db
|
|
29
|
+
"Bash(bunx x verify:*)",
|
|
30
|
+
"Bash(bunx x test:*)",
|
|
31
|
+
"Bash(bunx x doctor:*)",
|
|
32
|
+
"Bash(bunx x g:*)",
|
|
33
|
+
"Bash(bunx x routes:*)",
|
|
34
|
+
"Bash(bunx x actions:*)",
|
|
35
|
+
"Bash(bunx x queries:*)",
|
|
36
|
+
"Bash(bunx x entities:*)",
|
|
37
|
+
"Bash(bunx x jobs list:*)",
|
|
38
|
+
"Bash(bunx x jobs show:*)",
|
|
39
|
+
"Bash(bunx x tasks:*)",
|
|
40
|
+
"Bash(bunx x policy:*)",
|
|
41
|
+
"Bash(bunx x i18n check:*)",
|
|
42
|
+
"Bash(bunx x errors:*)",
|
|
43
|
+
"Bash(bunx x docs:*)",
|
|
44
|
+
"Bash(bunx x env check:*)",
|
|
45
|
+
"Bash(bunx x manifest:*)",
|
|
46
|
+
"Bash(bunx x db gen:*)",
|
|
47
|
+
"Bash(bunx x db migrate:*)",
|
|
48
|
+
"Bash(bunx x db branch:*)",
|
|
46
49
|
"Bash(git status)",
|
|
47
50
|
"Bash(git diff:*)",
|
|
48
51
|
"Bash(git log:*)"
|
|
@@ -68,7 +71,7 @@ const readme = (app: NameSet): string => `# .claude/
|
|
|
68
71
|
|
|
69
72
|
What Claude Code reads when it works on ${app.kebab}. Written by \`x new\`, owned by you from the
|
|
70
73
|
moment it lands: edit any file, or delete any file, and nothing in the app breaks. Nothing here is
|
|
71
|
-
enforced by \`x verify\`, and nothing here reaches outside this repository.
|
|
74
|
+
enforced by \`bunx x verify\`, and nothing here reaches outside this repository.
|
|
72
75
|
|
|
73
76
|
One thing in it does *write*: the \`PostToolUse\` hook in \`settings.json\` reformats each file an agent
|
|
74
77
|
edits, in place, the moment it is saved. That is the only thing here that touches your code on its
|
|
@@ -86,7 +89,7 @@ So the whole bundle is three description lines of standing cost. The rest is pai
|
|
|
86
89
|
|
|
87
90
|
| File | For |
|
|
88
91
|
|---|---|
|
|
89
|
-
| \`commands/feature.md\` | \`/feature\` — build or fix one thing end to end, gated on \`
|
|
92
|
+
| \`commands/feature.md\` | \`/feature\` — build or fix one thing end to end, gated on \`bun run verify\` |
|
|
90
93
|
| \`commands/planx.md\` | \`/planx\` — write a plan to \`docs/plans/\` for another agent to execute |
|
|
91
94
|
| \`commands/verify.md\` | \`/verify\` — run the gate and fix what it reports |
|
|
92
95
|
| \`agents/shape.md\` | the idea-stage pass: which primitive, which slice, or "do not build this" |
|
|
@@ -99,7 +102,7 @@ a line it may not cross. That is what makes two of them safe to run at once.
|
|
|
99
102
|
|
|
100
103
|
## What is deliberately not here
|
|
101
104
|
|
|
102
|
-
**No command that wraps \`x g\`.** \`x g <kind> <name>\` already is that command, and \`x g --help\` is
|
|
105
|
+
**No command that wraps \`bunx x g\`.** \`bunx x g <kind> <name>\` already is that command, and \`bunx x g --help\` is
|
|
103
106
|
the only list of kinds — a slash command restating it is a second copy that drifts the first time a
|
|
104
107
|
kind is added.
|
|
105
108
|
|
|
@@ -113,7 +116,7 @@ means it rewrites that file in place** — the same safe fixes \`biome check --w
|
|
|
113
116
|
repo-wide, but your working tree does change after every agent edit. Scoped to one file, so it costs
|
|
114
117
|
milliseconds, and it settles formatting arguments before they reach a diff.
|
|
115
118
|
|
|
116
|
-
It is not a typecheck, because a *scoped* one does not exist: \`tsc\` needs the project, and \`x verify\`
|
|
119
|
+
It is not a typecheck, because a *scoped* one does not exist: \`tsc\` needs the project, and \`bunx x verify\`
|
|
117
120
|
takes no \`--only\` and no \`--skip\` by design — narrowing the gate would make "green" mean whatever
|
|
118
121
|
the caller chose. If you want types on every edit, \`bun run typecheck\` is the whole project and you
|
|
119
122
|
are choosing to pay for it.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `x deploy --method compose` runs, and the single-node rung of the scale ladder.
|
|
4
4
|
|
|
5
5
|
import type { NameSet } from './naming';
|
|
6
|
+
import { RUNTIME_UID } from './scaffold-helm';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The production env file, relative to the app root. Named once because two readers must agree:
|
|
@@ -19,6 +20,8 @@ export const composeProdFile = (
|
|
|
19
20
|
#
|
|
20
21
|
# IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
|
|
21
22
|
#
|
|
23
|
+
# ${PROD_ENV_FILE} first: \`x env init --production\` writes every value below with fresh secrets.
|
|
24
|
+
#
|
|
22
25
|
# By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
|
|
23
26
|
# and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
|
|
24
27
|
# "missing" and the parse fails. \`x deploy\` passes it on every step.
|
|
@@ -55,8 +58,10 @@ x-image: &image
|
|
|
55
58
|
# filesystem, no Linux capabilities (nothing here binds below 1024), no setuid escalation, and a
|
|
56
59
|
# memory ceiling so one runaway role takes down its container rather than the box. /tmp and .x/
|
|
57
60
|
# are tmpfs — .x/ is where a still-embedded binding keeps state, which production never relies on.
|
|
61
|
+
# A tmpfs mounts root-owned and hides the image's chown, so .x/ names the image's USER: without
|
|
62
|
+
# it every role refuses at boot (X_STORAGE_UNWRITABLE, EACCES mkdir '/app/.x/storage').
|
|
58
63
|
read_only: true
|
|
59
|
-
tmpfs: [/tmp, /app/.x]
|
|
64
|
+
tmpfs: [/tmp, '/app/.x:uid=${RUNTIME_UID},gid=${RUNTIME_UID}']
|
|
60
65
|
cap_drop: [ALL]
|
|
61
66
|
security_opt: ['no-new-privileges:true']
|
|
62
67
|
mem_limit: 1g
|
|
@@ -121,6 +121,10 @@ ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
|
121
121
|
* has ever produced ignoring a filename nothing writes, which reads as a rule still in force.
|
|
122
122
|
* `scripts/image-contract.ts` holds every ignore file IN THIS TREE to the same line; a generated
|
|
123
123
|
* app's copy is this template's, and `scaffold-container.test.ts` is where it is judged.
|
|
124
|
+
*
|
|
125
|
+
* Tests and the root `docs/` go for a different reason: `COPY . .` sits above the prebuilt step, so
|
|
126
|
+
* an edit to a file no boot reads reran Babel and Sass. No `*.fixture.ts` line: non-test source can
|
|
127
|
+
* reach a fixture, and a missing module at boot is a worse failure than a few kilobytes.
|
|
124
128
|
*/
|
|
125
129
|
const dockerignore = (): string => `**/.env
|
|
126
130
|
**/.env.*
|
|
@@ -143,6 +147,11 @@ node_modules
|
|
|
143
147
|
coverage
|
|
144
148
|
**/test-results
|
|
145
149
|
**/playwright-report
|
|
150
|
+
# Tests run in CI, never in a container, and an edit to one would rerun every line below COPY . .
|
|
151
|
+
**/*.test.ts
|
|
152
|
+
**/*.test.tsx
|
|
153
|
+
# Anchored: a package may hold a docs/ it reads at run time. See "What the image leaves out" in README.
|
|
154
|
+
docs
|
|
146
155
|
`;
|
|
147
156
|
|
|
148
157
|
const readme = (app: NameSet): string => `# docker
|
|
@@ -175,6 +184,12 @@ compiled stylesheet is made once, there, by the image's own Bun — so a pod bui
|
|
|
175
184
|
Without that line every boot runs Babel and Sass over the whole app and logs
|
|
176
185
|
\`X_IMAGE_NOT_PREBUILT\`, naming it.
|
|
177
186
|
|
|
187
|
+
### What the image leaves out
|
|
188
|
+
|
|
189
|
+
\`docker/Dockerfile.dockerignore\` drops \`*.test.ts\`, \`*.test.tsx\` and the root \`docs/\`: no boot
|
|
190
|
+
reads them, and an edit to one leaves the prebuilt layer cached. An app that serves a file from
|
|
191
|
+
\`docs/\` re-includes it with \`!docs/<path>\`, on a line after \`docs\`.
|
|
192
|
+
|
|
178
193
|
## One box, every role
|
|
179
194
|
|
|
180
195
|
\`\`\`sh
|
|
@@ -276,10 +291,15 @@ somebody else's repository is a command that cannot run. \`--image\` sets \`imag
|
|
|
276
291
|
| Job | release | \`ROLE=migrate\`, a \`pre-install,pre-upgrade\` hook — it runs to completion first |
|
|
277
292
|
| Ingress | release | off by default; routes \`/_x/sync\` to sync and \`/\` to web |
|
|
278
293
|
| HorizontalPodAutoscaler | role, opt-in | rps, websocket connections, queue depth — never CPU |
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
294
|
+
| ServiceMonitor | release | scrapes \`/metrics\` on \`metricsPort\`, every serving role |
|
|
295
|
+
| PrometheusRule | release | stalled queue, dead letters, lost leases, dropped frames, 5xx share, target down |
|
|
296
|
+
|
|
297
|
+
The last two render only where the cluster serves the Prometheus operator's kinds
|
|
298
|
+
(\`serviceMonitor.enabled\` and \`alerts.enabled\` are \`auto\`), so \`helm install\` still succeeds on a
|
|
299
|
+
cluster without it; \`true\` forces them, \`false\` declines. Each rule's threshold, \`for\` and
|
|
300
|
+
severity are values under \`alerts.rules.<alert>\`. Outside Kubernetes, \`x g chart-alerts --print\`
|
|
301
|
+
prints the same rules for any Prometheus rule loader. No PodDisruptionBudget: that is cluster
|
|
302
|
+
policy rather than this app's topology.
|
|
283
303
|
`;
|
|
284
304
|
|
|
285
305
|
/** The container files for a new app, in the order a reader meets them. */
|
|
@@ -203,7 +203,7 @@ afterAll(clearRoutes);
|
|
|
203
203
|
|
|
204
204
|
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
205
205
|
const view = await renderRoute(page, { url });
|
|
206
|
-
expect(page.config.render).toBe('
|
|
206
|
+
expect(page.config.render).toBe('stream');
|
|
207
207
|
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
208
208
|
expect(page.config.offline).toBe('runtime');
|
|
209
209
|
expect(page.config.budget.js).toBe('60kb');
|
|
@@ -230,12 +230,12 @@ unitTest('the table is the route table: path, surface and render mode per route'
|
|
|
230
230
|
expect([...row.matchAll(/([^>]*)<\\/td>/g)].map((cell) => cell[1])).toEqual([
|
|
231
231
|
'/dashboard',
|
|
232
232
|
'app',
|
|
233
|
-
'
|
|
233
|
+
'stream',
|
|
234
234
|
]);
|
|
235
235
|
expect(view.text).toContain(t('app.dashboard.routesCaption'));
|
|
236
236
|
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
237
237
|
// The sidebar knows where it is.
|
|
238
|
-
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
238
|
+
expect(view.html).toMatch(/<a [^>]*href="\\/dashboard" aria-current="page"/);
|
|
239
239
|
});
|
|
240
240
|
`;
|
|
241
241
|
|
|
@@ -12,6 +12,8 @@ export const exampleViewTest =
|
|
|
12
12
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
13
13
|
import {
|
|
14
14
|
bucketByDay,
|
|
15
|
+
CHART_MAX_DAYS,
|
|
16
|
+
chartDays,
|
|
15
17
|
formatCount,
|
|
16
18
|
type PostRow,
|
|
17
19
|
postStats,
|
|
@@ -43,9 +45,14 @@ unitTest('postStats counts the week, compares it with the prior one, and counts
|
|
|
43
45
|
expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
|
|
44
46
|
});
|
|
45
47
|
|
|
46
|
-
unitTest('a zero baseline
|
|
47
|
-
expect(postStats([row('a', 1)], NOW).delta).
|
|
48
|
-
expect(postStats([], NOW)).toEqual({
|
|
48
|
+
unitTest('from a zero baseline the chip is the difference, and no rows count nothing', () => {
|
|
49
|
+
expect(postStats([row('a', 1)], NOW).delta).toEqual({ text: '+1', trend: 'up' });
|
|
50
|
+
expect(postStats([], NOW)).toEqual({
|
|
51
|
+
total: 0,
|
|
52
|
+
lastWeek: 0,
|
|
53
|
+
delta: { text: '0', trend: 'flat' },
|
|
54
|
+
today: 0,
|
|
55
|
+
});
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
|
|
@@ -53,12 +60,27 @@ unitTest('bucketByDay is one bar per day, oldest first, quiet days included', ()
|
|
|
53
60
|
expect(points).toHaveLength(14);
|
|
54
61
|
expect(points.at(-1)?.key).toBe('03-15');
|
|
55
62
|
expect(points[0]?.key).toBe('03-02');
|
|
56
|
-
expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
|
|
63
|
+
expect(points.reduce((sum, point) => sum + (point.value ?? 0), 0)).toBe(3);
|
|
57
64
|
// Eight days ago is 03-07: inside a 14-day window, so the bar is there.
|
|
58
65
|
expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
|
|
59
66
|
expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
|
|
60
67
|
});
|
|
61
68
|
|
|
69
|
+
unitTest('a capped read leaves the days before its oldest row unknown — null, never 0', () => {
|
|
70
|
+
const values = bucketByDay([row('a', 1), row('b', 3)], NOW, 5, { capped: true }).map(
|
|
71
|
+
(point) => point.value,
|
|
72
|
+
);
|
|
73
|
+
// 03-11 is before the oldest row the capped read saw (03-12): unknown. 03-13 is a quiet day: 0.
|
|
74
|
+
expect(values).toEqual([null, 1, 0, 1, 0]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
unitTest('chartDays is the window in whole days, never zero and never past the cap', () => {
|
|
78
|
+
expect(chartDays(new Date(NOW.getTime() - 7 * DAY_MS), NOW)).toBe(7);
|
|
79
|
+
expect(chartDays(new Date(NOW.getTime() - DAY_MS / 2), NOW)).toBe(1);
|
|
80
|
+
// A URL may ask for ten years: the chart draws at most this many bars.
|
|
81
|
+
expect(chartDays(new Date(NOW.getTime() - 3650 * DAY_MS), NOW)).toBe(CHART_MAX_DAYS);
|
|
82
|
+
});
|
|
83
|
+
|
|
62
84
|
unitTest('toPostRow serialises a Date, passes ISO text through, and keeps the price whole', () => {
|
|
63
85
|
const at = new Date('2026-01-02T03:04:05.000Z');
|
|
64
86
|
const price = { minor: 1900, currency: 'USD' };
|
|
@@ -139,7 +161,7 @@ afterEach(() => {
|
|
|
139
161
|
|
|
140
162
|
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
141
163
|
const view = await renderRoute(page, { url, actor: viewer });
|
|
142
|
-
expect(page.config.render).toBe('
|
|
164
|
+
expect(page.config.render).toBe('stream');
|
|
143
165
|
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
144
166
|
expect(page.config.offline).toBe('runtime');
|
|
145
167
|
expect(page.config.budget.js).toBe('60kb');
|
|
@@ -169,8 +191,11 @@ unitTest('it counts and lists the posts of the org that is looking', async () =>
|
|
|
169
191
|
expect(view.html.match(/data-segment="/g)).toHaveLength(2);
|
|
170
192
|
expect(view.html).toContain('role="meter"');
|
|
171
193
|
expect(view.text).toContain('This week');
|
|
194
|
+
// The chart's window is the default two weeks, and every preset is a link.
|
|
195
|
+
expect(view.html).toContain('href="/dashboard?range=week"');
|
|
196
|
+
expect(view.html).toContain('href="/dashboard?range=month"');
|
|
172
197
|
// The sidebar knows where it is.
|
|
173
|
-
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
198
|
+
expect(view.html).toMatch(/<a [^>]*href="\\/dashboard" aria-current="page"/);
|
|
174
199
|
});
|
|
175
200
|
|
|
176
201
|
unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
|
|
@@ -185,6 +210,18 @@ unitTest('another org sees none of them: the page says so instead of an empty ta
|
|
|
185
210
|
expect(view.meta.description).toBe(t('app.dashboard.description'));
|
|
186
211
|
});
|
|
187
212
|
|
|
213
|
+
unitTest('the chart window is read from the URL and survives as a link', async () => {
|
|
214
|
+
await post('This week', 3);
|
|
215
|
+
const week = await renderRoute(page, { url: \`\${url}?range=week\`, actor: viewer });
|
|
216
|
+
expect(week.data.url).toBe(\`\${url}?range=week\`);
|
|
217
|
+
// Seven bars for the week; the default window draws fourteen.
|
|
218
|
+
const keys = (html: string): number =>
|
|
219
|
+
html.match(/<th[^>]*scope="row"[^>]*>\\d{2}-\\d{2}</g)?.length ?? 0;
|
|
220
|
+
const fortnight = await renderRoute(page, { url, actor: viewer });
|
|
221
|
+
expect(keys(week.html)).toBe(7);
|
|
222
|
+
expect(keys(fortnight.html)).toBe(14);
|
|
223
|
+
});
|
|
224
|
+
|
|
188
225
|
unitTest('a viewer the post read refuses is refused, never shown the org’s posts', async () => {
|
|
189
226
|
await post('Not for them', 0);
|
|
190
227
|
// In the org, onto the page, without \`post:read\`: the query's policy decides, not the page's.
|
|
@@ -195,14 +232,17 @@ unitTest('a viewer the post read refuses is refused, never shown the org’s pos
|
|
|
195
232
|
});
|
|
196
233
|
|
|
197
234
|
unitTest('the build measures the empty page: no database is not a failure there', async () => {
|
|
198
|
-
const measured = await page.dashboardData(
|
|
235
|
+
const measured = await page.dashboardData(
|
|
236
|
+
() => Promise.reject(dbUnavailable('no database')),
|
|
237
|
+
url,
|
|
238
|
+
);
|
|
199
239
|
// The clock is frozen under test, so the instant the windows are cut at is this one.
|
|
200
|
-
expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
|
|
240
|
+
expect(measured).toEqual({ rows: [], now: new Date().toISOString(), url });
|
|
201
241
|
});
|
|
202
242
|
|
|
203
243
|
unitTest('any other failure of the read propagates', async () => {
|
|
204
244
|
const cause = new TypeError('the read broke');
|
|
205
245
|
const read = (): Promise<never> => Promise.reject(cause);
|
|
206
|
-
expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
|
|
246
|
+
expect(await page.dashboardData(read, url).catch((error: unknown) => error)).toBe(cause);
|
|
207
247
|
});
|
|
208
248
|
`;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The `--example` dashboard's view module, `dashboard-view.ts`: the pure numbers behind the page —
|
|
2
|
+
// stats, per-day buckets, the chart's window presets and its day bound. Split from
|
|
3
|
+
// `scaffold-dashboard-example.ts`, which keeps the page that reads it.
|
|
4
|
+
|
|
5
|
+
export const exampleView =
|
|
6
|
+
(): string => `// The dashboard's numbers, apart from its markup: pure functions over the rows the page loaded,
|
|
7
|
+
// so the server render and a test cannot disagree about a figure. No I/O, no \`t()\`, no JSX.
|
|
8
|
+
|
|
9
|
+
import type { Money } from '@ultimat3/money';
|
|
10
|
+
import { type ChartPoint, deltaOf, type StatDelta } from '@ultimat3/ui';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The chart's windows, shortest first. The list is this app's — "last 24 hours" or "this quarter"
|
|
14
|
+
* is a business decision — and \`timeRangeUrl\` is the one rule that reads it from the URL.
|
|
15
|
+
*/
|
|
16
|
+
export const CHART_RANGES = [
|
|
17
|
+
{ id: 'week', days: 7 },
|
|
18
|
+
{ id: 'fortnight', days: 14 },
|
|
19
|
+
{ id: 'month', days: 30 },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_CHART_RANGE = 'fortnight';
|
|
23
|
+
|
|
24
|
+
/** The chart buckets UTC days, so a custom window's wall-clock times are UTC too: one zone. */
|
|
25
|
+
export const CHART_ZONE = 'UTC';
|
|
26
|
+
|
|
27
|
+
/** The most bars a custom window draws: one per day, so this bounds the markup a URL can ask for. */
|
|
28
|
+
export const CHART_MAX_DAYS = 92;
|
|
29
|
+
|
|
30
|
+
const DAY_MS = 86_400_000;
|
|
31
|
+
|
|
32
|
+
/** Whole days a window covers, at least one and at most \`CHART_MAX_DAYS\`. */
|
|
33
|
+
export function chartDays(from: Date, to: Date): number {
|
|
34
|
+
const days = Math.ceil((to.getTime() - from.getTime()) / DAY_MS);
|
|
35
|
+
return Math.min(CHART_MAX_DAYS, Math.max(1, days));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One post as the page reads it. \`createdAt\` is ISO text: a \`Date\` cannot cross a JSON seam. */
|
|
39
|
+
export interface PostRow {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly title: string;
|
|
42
|
+
/** Integer minor units plus an ISO code, never a float — the entity's \`money()\` column. */
|
|
43
|
+
readonly price: Money;
|
|
44
|
+
readonly createdAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Duck-typed rather than the entity's own row: the page only ever reads these four columns. The
|
|
49
|
+
* query reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
|
|
50
|
+
*/
|
|
51
|
+
export function toPostRow(row: {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly title: string;
|
|
54
|
+
readonly price: Money;
|
|
55
|
+
readonly createdAt: Date | string;
|
|
56
|
+
}): PostRow {
|
|
57
|
+
const at = row.createdAt;
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
title: row.title,
|
|
61
|
+
price: row.price,
|
|
62
|
+
createdAt: at instanceof Date ? at.toISOString() : at,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Free (a zero price) against paid, over every row loaded — the two halves of the ring. */
|
|
67
|
+
export interface PriceMix {
|
|
68
|
+
readonly free: number;
|
|
69
|
+
readonly paid: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function priceMix(rows: readonly PostRow[]): PriceMix {
|
|
73
|
+
const paid = rows.filter((row) => row.price.minor > 0).length;
|
|
74
|
+
return { free: rows.length - paid, paid };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The dearest price per currency, in minor units — what each row's in-cell bar is a share of.
|
|
79
|
+
* Per currency because 1,900 cents and 1,900 yen are not the same height.
|
|
80
|
+
*/
|
|
81
|
+
export function priceCeilings(rows: readonly PostRow[]): ReadonlyMap<string, number> {
|
|
82
|
+
const ceilings = new Map<string, number>();
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const { currency, minor } = row.price;
|
|
85
|
+
ceilings.set(currency, Math.max(ceilings.get(currency) ?? 0, minor));
|
|
86
|
+
}
|
|
87
|
+
return ceilings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PostStats {
|
|
91
|
+
readonly total: number;
|
|
92
|
+
/** Created in the seven days ending at \`now\`. */
|
|
93
|
+
readonly lastWeek: number;
|
|
94
|
+
/** \`lastWeek\` against the seven days before it: a percentage, or the difference from zero. */
|
|
95
|
+
readonly delta: StatDelta | undefined;
|
|
96
|
+
/** Created in the UTC day \`now\` falls in — a partial day, so it carries no delta. */
|
|
97
|
+
readonly today: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const createdAtOf = (row: PostRow): number => Date.parse(row.createdAt);
|
|
101
|
+
|
|
102
|
+
const within = (rows: readonly PostRow[], from: number, to: number): number =>
|
|
103
|
+
rows.filter((row) => {
|
|
104
|
+
const at = createdAtOf(row);
|
|
105
|
+
return Number.isFinite(at) && at > from && at <= to;
|
|
106
|
+
}).length;
|
|
107
|
+
|
|
108
|
+
export function postStats(rows: readonly PostRow[], now: Date): PostStats {
|
|
109
|
+
const end = now.getTime();
|
|
110
|
+
const weekAgo = end - 7 * DAY_MS;
|
|
111
|
+
const dayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
112
|
+
const lastWeek = within(rows, weekAgo, end);
|
|
113
|
+
return {
|
|
114
|
+
total: rows.length,
|
|
115
|
+
lastWeek,
|
|
116
|
+
delta: deltaOf(lastWeek, within(rows, weekAgo - 7 * DAY_MS, weekAgo)),
|
|
117
|
+
today: within(rows, dayStart - 1, end),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** \`MM-DD\` of a UTC day. ISO, never \`toLocaleDateString\`: the chart's axis is data, not prose. */
|
|
122
|
+
const dayKey = (at: number): string => new Date(at).toISOString().slice(5, 10);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One bucket per UTC day, the last one being the day \`now\` falls in, oldest first — the shape
|
|
126
|
+
* \`BarChart\` draws. Every day is present even when nothing was created on it: a chart with the
|
|
127
|
+
* quiet days removed reads as busier than the app is. A quiet day is 0; a day the read could not
|
|
128
|
+
* see — before the oldest row of a \`capped\` read — is \`null\`, a gap, never a zero.
|
|
129
|
+
*/
|
|
130
|
+
export function bucketByDay(
|
|
131
|
+
rows: readonly PostRow[],
|
|
132
|
+
now: Date,
|
|
133
|
+
days: number,
|
|
134
|
+
options: { readonly capped?: boolean } = {},
|
|
135
|
+
): readonly ChartPoint[] {
|
|
136
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
137
|
+
const start = today - (days - 1) * DAY_MS;
|
|
138
|
+
const counts: number[] = Array.from({ length: days }, () => 0);
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
const at = createdAtOf(row);
|
|
141
|
+
if (!Number.isFinite(at)) continue;
|
|
142
|
+
const index = Math.floor((at - start) / DAY_MS);
|
|
143
|
+
if (index >= 0 && index < days) counts[index] = (counts[index] ?? 0) + 1;
|
|
144
|
+
}
|
|
145
|
+
const oldest = Math.min(...rows.map(createdAtOf).filter(Number.isFinite));
|
|
146
|
+
const seenFrom = options.capped === true ? Math.floor((oldest - start) / DAY_MS) : 0;
|
|
147
|
+
return counts.map((value, index) => ({
|
|
148
|
+
key: dayKey(start + index * DAY_MS),
|
|
149
|
+
value: index < seenFrom ? null : value,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A figure for a tile, in the page's locale. \`Intl.NumberFormat\` needs no time zone. */
|
|
154
|
+
export const formatCount = (value: number, locale: string): string =>
|
|
155
|
+
new Intl.NumberFormat(locale).format(value);
|
|
156
|
+
`;
|