@ultimat3/cli 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
@@ -1,19 +1,25 @@
1
- // The CI half of what `x new` writes: one workflow that runs the app's OWN two commands.
1
+ // The CI half of what `x new` writes: the gate workflow that runs the app's OWN two commands, and
2
+ // the image workflow (`image.yml.ts`) that publishes only what that gate passed.
2
3
  //
3
4
  // Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
4
- // life with a gate that ran on exactly one machine — the author's. The two commands are `bin/setup`
5
- // and `bin/check` and NOT a restatement of their steps: a workflow that spelled out `bun install`,
6
- // `x db migrate`, `x build` and `x verify` is a second definition of the gate, free to drift from
7
- // the scripts a human runs, and axiom 1 has one way to do each thing. `bin/check` is also not a
8
- // synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
5
+ // life with a gate that ran on exactly one machine — the author's. The two commands are
6
+ // `bun run setup` and `bun run check` and NOT a restatement of their steps: a workflow that spelled
7
+ // out `bun install`, `x db migrate`, `x build` and `x verify` is a second definition of the gate,
8
+ // free to drift from the scripts a human runs, and axiom 1 has one way to do each thing.
9
+ // `bun run check` is also not a synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
9
10
  // measurable rather than X_BUDGET_UNMEASURED.
10
11
 
11
12
  import { REQUIRED_BUN } from '../../app-root';
12
13
  import type { GeneratedFile, NameSet } from '../naming';
14
+ import { IMAGE_WORKFLOW_PATH, imageWorkflow } from './image.yml';
15
+ import { RUNS_ON } from './runner';
13
16
 
14
17
  /** Where the workflow lands. GitHub reads this path and no other. */
15
18
  export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
16
19
 
20
+ /** The workflow's `name:` — what `image.yml`'s `workflow_run` trigger waits on, spelled once. */
21
+ const CI_WORKFLOW_NAME = 'ci';
22
+
17
23
  /**
18
24
  * Pinned by commit SHA with the version in the trailing comment, because this step runs before any
19
25
  * code of yours does and `@v2` is a tag its owner can move. `actions/*` follows its major tag
@@ -22,19 +28,19 @@ export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
22
28
  */
23
29
  const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0';
24
30
 
25
- const ci = (app: NameSet): string => `name: ci
31
+ const ci = (app: NameSet): string => `name: ${CI_WORKFLOW_NAME}
26
32
 
27
33
  # The gate for ${app.kebab}: the same two commands \`README.md\` tells a human to run, in the same
28
34
  # order, on a machine that has never seen this repository. A check that exists only in CI is one
29
35
  # nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
30
36
  #
31
- # \`bin/check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a convenience:
32
- # the gate's \`budgets\` step compares declared limits against measured bytes in
37
+ # \`bun run check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a
38
+ # convenience: the gate's \`budgets\` step compares declared limits against measured bytes in
33
39
  # \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
34
40
  # instead and this workflow is red for a reason that has nothing to do with the commit.
35
41
  #
36
- # No \`services:\` block, deliberately — \`bin/setup\` brings up embedded Postgres in-process, so
37
- # there is nothing to provision and nothing to wait on. Add one the day this app needs a real
42
+ # No \`services:\` block, deliberately — \`bun run setup\` brings up embedded Postgres in-process,
43
+ # so there is nothing to provision and nothing to wait on. Add one the day this app needs a real
38
44
  # server, beside the \`DATABASE_URL\` that selects it.
39
45
 
40
46
  # EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
@@ -61,7 +67,8 @@ jobs:
61
67
  # the push run already gates that exact tree — so the pull_request run is skipped, not paid for
62
68
  # twice. A pull request from a FORK fires no push here, and it still runs.
63
69
  if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
64
- runs-on: ubuntu-latest
70
+ # A free GitHub-hosted runner unless the repository variable \`CI_RUNNER\` names another label.
71
+ runs-on: ${RUNS_ON}
65
72
  # A bound on a HANG, not on cost: a step that never returns holds a runner until GitHub's
66
73
  # six-hour default expires, and the failure is invisible for all six of them.
67
74
  timeout-minutes: 20
@@ -74,19 +81,22 @@ jobs:
74
81
  # whoever took the app at its word. Never \`latest\`: a Bun minor landing unannounced is a
75
82
  # runtime change nobody chose.
76
83
  bun-version: '${REQUIRED_BUN}'
77
- # Bun's download cache, keyed on the lockfile: \`bin/setup\`'s install then links from disk
78
- # instead of fetching every package again on every run.
84
+ # Bun's download cache, keyed on the lockfile: \`bun run setup\`'s install then links from
85
+ # disk instead of fetching every package again on every run.
79
86
  - uses: actions/cache@v6
80
87
  with:
81
88
  path: ~/.bun/install/cache
82
89
  key: bun-\${{ runner.os }}-\${{ hashFiles('bun.lock') }}
83
90
  restore-keys: bun-\${{ runner.os }}-
84
91
  # Two steps rather than one \`&&\`, so the log names which half failed and times each.
85
- - run: bin/setup
86
- - run: bin/check
92
+ - run: bun run setup
93
+ - run: bun run check
87
94
  `;
88
95
 
89
- /** The CI a new app is born with, in the order a reader meets it. */
96
+ /** The CI a new app is born with, in the order a reader meets it: the gate, then the image. */
90
97
  export function githubFiles(app: NameSet): readonly GeneratedFile[] {
91
- return [{ path: CI_WORKFLOW_PATH, contents: ci(app) }];
98
+ return [
99
+ { path: CI_WORKFLOW_PATH, contents: ci(app) },
100
+ { path: IMAGE_WORKFLOW_PATH, contents: imageWorkflow(CI_WORKFLOW_NAME) },
101
+ ];
92
102
  }
@@ -0,0 +1,92 @@
1
+ // The image half of what `x new` writes under `.github/`: publish `docker/Dockerfile` to the
2
+ // repository's own GitHub Container Registry, only for a commit `ci.yml` already passed on the
3
+ // default branch. It runs no gate of its own — a second gate is a second definition of one — and
4
+ // it deploys nothing: the image digest is the handoff, axiom 7's one deploy artifact.
5
+
6
+ import { DEFAULT_RUNNER } from './runner';
7
+
8
+ /** Where the workflow lands, beside the gate it waits on. */
9
+ export const IMAGE_WORKFLOW_PATH = '.github/workflows/image.yml';
10
+
11
+ /** The Dockerfile `containerFiles` writes — the one image every role runs. */
12
+ export const IMAGE_DOCKERFILE = 'docker/Dockerfile';
13
+
14
+ /** `gate` is the `name:` of the workflow whose verdict this one waits on — `ci.yml`'s own. */
15
+ export const imageWorkflow = (gate: string): string => `name: image
16
+
17
+ # Builds ${IMAGE_DOCKERFILE} and pushes it to ghcr.io/<owner>/<repo>, tagged \`sha-<7>\` and the full
18
+ # commit SHA — never \`latest\`, a pointer a late run moves backwards. Only GITHUB_TOKEN: GHCR takes
19
+ # the job's own token, so there is no secret to create.
20
+ #
21
+ # Triggered by \`${gate}\` FINISHING, never by the push both would start from: a second
22
+ # workflow on the same push is not a dependency, and this one must publish only what the gate
23
+ # judged. A \`workflow_run\` has no branch filter here for the reason ci.yml's \`on:\` has none —
24
+ # \`x new\` never decided what the default branch is called — so the job's \`if:\` asks GitHub.
25
+ on:
26
+ workflow_run:
27
+ workflows: [${gate}]
28
+ types: [completed]
29
+
30
+ # One publish per commit, never cancelled mid-push: a half-pushed manifest is worse than a queue.
31
+ concurrency:
32
+ group: image-\${{ github.event.workflow_run.head_sha }}
33
+ cancel-in-progress: false
34
+
35
+ permissions:
36
+ contents: read
37
+ packages: write
38
+
39
+ jobs:
40
+ publish:
41
+ # A green gate, from a push (never a fork's pull request, whose head would build with a
42
+ # registry token in scope), to the default branch of THIS repository.
43
+ if: >-
44
+ github.event.workflow_run.conclusion == 'success' &&
45
+ github.event.workflow_run.event == 'push' &&
46
+ github.event.workflow_run.head_branch == github.event.repository.default_branch &&
47
+ github.event.workflow_run.head_repository.full_name == github.repository
48
+ # NOT \`vars.CI_RUNNER\`: the gate may move to any runner, but this job needs bash (\`\${SHA::7}\`,
49
+ # \`\${GITHUB_REPOSITORY,,}\`) and a Docker daemon, and a Windows or self-hosted label may have
50
+ # neither — a green gate would then publish nothing. The free GitHub-hosted Linux runner has both.
51
+ runs-on: ${DEFAULT_RUNNER}
52
+ defaults:
53
+ run:
54
+ shell: bash
55
+ timeout-minutes: 30
56
+ steps:
57
+ # The commit the gate judged, not the tip of the branch when this run started.
58
+ - uses: actions/checkout@v7
59
+ with:
60
+ ref: \${{ github.event.workflow_run.head_sha }}
61
+ persist-credentials: false
62
+ # GHCR refuses an upper-case repository path, and \`github.repository\` keeps the owner's case.
63
+ - name: name the image
64
+ id: image
65
+ env:
66
+ SHA: \${{ github.event.workflow_run.head_sha }}
67
+ run: |
68
+ repo="ghcr.io/\${GITHUB_REPOSITORY,,}"
69
+ {
70
+ echo "short=\${repo}:sha-\${SHA::7}"
71
+ echo "full=\${repo}:\${SHA}"
72
+ } >> "$GITHUB_OUTPUT"
73
+ # The plain docker CLI every GitHub-hosted runner ships — no third-party action holds the
74
+ # token. BUILD_ID is the commit, the stamp ${IMAGE_DOCKERFILE} documents.
75
+ - name: log in to ghcr.io
76
+ env:
77
+ TOKEN: \${{ github.token }}
78
+ run: printf '%s' "$TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
79
+ - name: build
80
+ env:
81
+ SHA: \${{ github.event.workflow_run.head_sha }}
82
+ SHORT: \${{ steps.image.outputs.short }}
83
+ FULL: \${{ steps.image.outputs.full }}
84
+ run: docker build -f ${IMAGE_DOCKERFILE} --build-arg "BUILD_ID=$SHA" -t "$SHORT" -t "$FULL" .
85
+ - name: push
86
+ env:
87
+ SHORT: \${{ steps.image.outputs.short }}
88
+ FULL: \${{ steps.image.outputs.full }}
89
+ run: |
90
+ docker push "$SHORT"
91
+ docker push "$FULL"
92
+ `;
@@ -0,0 +1,18 @@
1
+ // The `runs-on:` the workflows `x new` writes: the gate on a free GitHub-hosted runner unless the
2
+ // repository says otherwise, the image build always on that default — it needs bash and Docker,
3
+ // which a configured label need not have. Spelled once so the two cannot drift.
4
+
5
+ /** The repository variable that overrides the runner — Settings → Secrets and variables → Actions. */
6
+ export const RUNNER_VARIABLE = 'CI_RUNNER';
7
+
8
+ /**
9
+ * The default label. GitHub-hosted and free for a public repository; a paid or self-hosted runner
10
+ * is a choice an owner makes by setting the variable, never one a scaffold makes for them.
11
+ */
12
+ export const DEFAULT_RUNNER = 'ubuntu-latest';
13
+
14
+ /**
15
+ * `vars.*` is an expression context `runs-on` accepts, and an unset variable reads as `''`, so the
16
+ * `||` falls through to the default on every repository that never set one.
17
+ */
18
+ export const RUNS_ON = `\${{ vars.${RUNNER_VARIABLE} || '${DEFAULT_RUNNER}' }}`;
@@ -28,9 +28,11 @@ const CODE = '${CODE}';
28
28
  /**
29
29
  * A THROW, never a construction. \`new Error(…)\` handed to something as INPUT is legitimate — a
30
30
  * test fixture, an \`AbortSignal\` reason, a rejection this module is passing along — and only the
31
- * throw is this module stating its own verdict.
31
+ * throw is this module stating its own verdict. \`new\` is optional — \`throw Error(…)\` builds the
32
+ * same bare error — and every builtin class is listed: each carries no code, cause or fix.
32
33
  */
33
- const BARE_THROW = /\\bthrow\\s+new\\s+(Error|TypeError|RangeError|SyntaxError)\\s*\\(/g;
34
+ const BARE_THROW =
35
+ /\\bthrow\\s+(?:new\\s+)?(Error|TypeError|RangeError|SyntaxError|ReferenceError|EvalError|URIError|AggregateError)\\s*\\(/g;
34
36
 
35
37
  export interface SourceFile {
36
38
  /** App-root-relative POSIX path, so the finding names the file an author opens. */
@@ -101,6 +103,18 @@ unitTest('TypeError and RangeError are the same rule', () => {
101
103
  expect(bareThrows(file("throw new RangeError('x');"))).toHaveLength(1);
102
104
  });
103
105
 
106
+ unitTest('a throw with no \`new\` builds the same bare Error', () => {
107
+ expect(bareThrows(file("throw Error('no post');"))).toHaveLength(1);
108
+ expect(bareThrows(file("throw TypeError('x');"))).toHaveLength(1);
109
+ });
110
+
111
+ unitTest('every builtin Error class is the same rule', () => {
112
+ for (const name of ['SyntaxError', 'ReferenceError', 'EvalError', 'URIError', 'AggregateError']) {
113
+ expect(bareThrows(file(\`throw new \${name}('x');\`))).toHaveLength(1);
114
+ }
115
+ expect(bareThrows(file("throw new MyTypeError('x');"))).toEqual([]);
116
+ });
117
+
104
118
  unitTest('an UltimateError subclass is what the rule asks for', () => {
105
119
  expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
106
120
  });
@@ -25,8 +25,29 @@ import type { Finding, Guard } from '@ultimat3/cli';
25
25
  /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
26
  const CODE = '${CODE}';
27
27
 
28
- /** Every call whose output depends on a zone. The \`(\` is the start of the argument list. */
29
- const FORMATTER = /(?:\\.toLocale(?:Date|Time)?String|Intl\\.DateTimeFormat)\\s*\\(/g;
28
+ /**
29
+ * Every call whose output depends on a zone. The \`(\` is the start of the argument list.
30
+ * \`.toDateString()\` / \`.toTimeString()\` take no options at all: always the host's zone.
31
+ */
32
+ const FORMATTER =
33
+ /(?:\\.toLocale(?:Date|Time)?String|\\.to(?:Date|Time)String|Intl\\.DateTimeFormat)\\s*\\(/g;
34
+
35
+ /**
36
+ * A \`timeZone\` KEY — written \`timeZone: z\`, the shorthand \`{ timeZone }\`, or quoted
37
+ * \`{ 'timeZone': z }\`, which is the same property. A substring test read \`timeZoneName\`, which
38
+ * only labels the zone the host already chose, as a zone. A quoted key takes only \`:\` — a
39
+ * quoted \`'timeZone',\` is a string in a list, not an option.
40
+ */
41
+ const ZONED = /\\btimeZone\\s*[:,}]|(['"])timeZone\\1\\s*:/;
42
+
43
+ /**
44
+ * The zoned call that replaces a formatter with no zone option. A \`Map\`, not an object: an
45
+ * object literal's prototype answers \`toLocaleString\`, and the bare call would read as one.
46
+ */
47
+ const ZONED_FORM: ReadonlyMap<string, string> = new Map([
48
+ ['toDateString', 'toLocaleDateString'],
49
+ ['toTimeString', 'toLocaleTimeString'],
50
+ ]);
30
51
 
31
52
  export interface SourceFile {
32
53
  /** App-root-relative POSIX path, so the finding names the file an author opens. */
@@ -72,19 +93,24 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
72
93
  const text = blank(file.source);
73
94
  for (const match of text.matchAll(FORMATTER)) {
74
95
  const open = match.index + match[0].length - 1;
75
- if (argumentsOf(text, open).includes('timeZone')) continue;
96
+ if (ZONED.test(argumentsOf(text, open))) continue;
76
97
  const line = lineOf(text, match.index);
77
98
  // The bare \`.toLocaleString(\` is ALSO \`Number.prototype.toLocaleString\`, and a regex cannot
78
99
  // tell a count from a date. The match stands — a date formatted this way is the defect this
79
100
  // guard exists for — but the fix names the number exit too, because \`{ timeZone }\` is not
80
101
  // one: a number ignores it, and an author following the fix verbatim would ship a lie.
81
102
  const bare = match[0].trim() === '.toLocaleString(';
103
+ const called = match[0].replaceAll(/[.(\\s]/g, '');
104
+ const zoned = ZONED_FORM.get(called);
82
105
  findings.push({
83
106
  code: CODE,
84
107
  cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
85
- fix: bare
86
- ? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
87
- : \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
108
+ fix:
109
+ zoned !== undefined
110
+ ? \`\${called}() has no zone option — in \${file.path} call \${zoned}(locale, { timeZone: 'UTC' }) instead — then: x verify\`
111
+ : bare
112
+ ? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
113
+ : \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
88
114
  at: file.path,
89
115
  });
90
116
  }
@@ -143,6 +169,30 @@ unitTest('the bare toLocaleString names the number exit, since a count matches i
143
169
  expect(dated[0]?.fix).not.toContain('Intl.NumberFormat');
144
170
  });
145
171
 
172
+ unitTest('timeZoneName labels the zone, it does not choose one', () => {
173
+ const named = "at.toLocaleString('en-US', { timeZoneName: 'short' });";
174
+ expect(unzonedDates(file(named))).toHaveLength(1);
175
+ const shorthand =
176
+ "new Intl.DateTimeFormat('en-US', { timeZoneName: 'short', timeZone }).format(at);";
177
+ expect(unzonedDates(file(shorthand))).toEqual([]);
178
+ });
179
+
180
+ unitTest('a quoted key is the same key, and a quoted timeZoneName is still only a label', () => {
181
+ expect(unzonedDates(file("at.toLocaleString('en-US', { 'timeZone': 'UTC' });"))).toEqual([]);
182
+ expect(unzonedDates(file('at.toLocaleString("en-US", { "timeZone": zone });'))).toEqual([]);
183
+ const label = "at.toLocaleString('en-US', { 'timeZoneName': 'short' });";
184
+ expect(unzonedDates(file(label))).toHaveLength(1);
185
+ });
186
+
187
+ unitTest('toDateString and toTimeString name the zoned call', () => {
188
+ const date = unzonedDates(file('const shown = at.toDateString();'));
189
+ expect(date).toHaveLength(1);
190
+ expect(date[0]?.fix).toContain('toLocaleDateString(locale');
191
+ const time = unzonedDates(file('const shown = at.toTimeString();'));
192
+ expect(time).toHaveLength(1);
193
+ expect(time[0]?.fix).toContain('toLocaleTimeString(locale');
194
+ });
195
+
146
196
  unitTest('a commented-out call is a note, not a call', () => {
147
197
  expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
148
198
  });
@@ -40,7 +40,7 @@ export { claudeAgentFiles } from './scaffold-claude-agents';
40
40
  export { claudeCommandFiles } from './scaffold-claude-commands';
41
41
  export { containerFiles } from './scaffold-container';
42
42
  export { dashboardFiles } from './scaffold-dashboard';
43
- export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
43
+ export { docsFiles } from './scaffold-docs';
44
44
  export { entryFiles } from './scaffold-entries';
45
45
  export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
46
46
  // The guards `x new` ships — `SHIPPED_GUARD_NAMES` is the list — distinct from `guardFiles` above,
@@ -28,7 +28,7 @@ const HEADER = (
28
28
  const storedRowTest = (name: NameSet): string => `
29
29
  unitTest('a stored row is loaded, then processed, once each', async ({ runJobs }) => {
30
30
  const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
31
- const row = await runWithContext(createContext({ actor: member }), () => repo.insert(draft));
31
+ const row = await runWithContext(ctxOf({ actor: member }), () => repo.insert(draft));
32
32
  const trace = await runJobs(${name.camel}, { id: row.id, orgId });
33
33
  expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
34
34
  expect(trace.executions[0]?.result).toEqual({ skipped: false });
@@ -41,7 +41,7 @@ const scopedBodyTest = (name: NameSet, shape: JobBodyShape): string => `${HEADER
41
41
  ${sortedImports([
42
42
  ...(shape.storesRow
43
43
  ? [
44
- "import { createContext, runWithContext } from '@ultimat3/core';",
44
+ "import { ctxOf, runWithContext } from '@ultimat3/core';",
45
45
  "import { testActor } from '@ultimat3/policy';",
46
46
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
47
47
  `import { driver } from '${shape.dbModule}';`,
@@ -129,7 +129,7 @@ const jobTest = (
129
129
  name: NameSet,
130
130
  ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
131
131
  // are the framework's, so what this pins is that THIS job's steps run and are idempotent.
132
- import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
132
+ import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
133
133
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
134
134
  import { ${name.camel} } from './${name.kebab}';
135
135
 
@@ -143,7 +143,7 @@ const expectedKey = \`${name.kebab}:\${id}\`;
143
143
  // The driver is process-global, so it is installed and released around this file rather than
144
144
  // left behind for whichever test happens to run next.
145
145
  beforeAll(() => {
146
- setJobDriver(createMemoryDriver());
146
+ setJobDriver(memoryJobDriver());
147
147
  });
148
148
  afterAll(resetJobDriver);
149
149
 
@@ -186,7 +186,7 @@ const neutralJobTest = (
186
186
  name: NameSet,
187
187
  ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
188
188
  // are the framework's, so what this pins is that THIS job's steps run and are idempotent.
189
- import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
189
+ import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
190
190
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
191
191
  import { ${name.camel} } from './${name.kebab}';
192
192
 
@@ -199,7 +199,7 @@ const expectedKey = \`${name.kebab}:\${id}\`;
199
199
  // The driver is process-global, so it is installed and released around this file rather than
200
200
  // left behind for whichever test happens to run next.
201
201
  beforeAll(() => {
202
- setJobDriver(createMemoryDriver());
202
+ setJobDriver(memoryJobDriver());
203
203
  });
204
204
  afterAll(resetJobDriver);
205
205
 
@@ -243,13 +243,13 @@ const taskTest = (
243
243
  jobName: NameSet,
244
244
  ): string => `// ${name.camel}: the schedule it declares, the timezone it declares it in, and the job it
245
245
  // enqueues. A cron with no explicit IANA zone fires at a different hour twice a year.
246
- import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
246
+ import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
247
247
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
248
248
  import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
249
249
  import { ${name.camel} } from './${name.kebab}';
250
250
 
251
251
  beforeAll(() => {
252
- setJobDriver(createMemoryDriver());
252
+ setJobDriver(memoryJobDriver());
253
253
  });
254
254
  afterAll(resetJobDriver);
255
255
 
@@ -63,7 +63,7 @@ const queryTest = (name: NameSet, feature: NameSet, shape: ReadShape): string =>
63
63
  const subscribes = live && shape.storesRow;
64
64
  const imports = subscribes
65
65
  ? [
66
- "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
66
+ "import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
67
67
  `import { afterEach, expect, ${wrapper} } from '@ultimat3/testing';`,
68
68
  `import { driver } from '${shape.dbModule}';`,
69
69
  ]
@@ -128,10 +128,11 @@ ${wrapper}('${name.camel} denies an orgless actor before a read', async () => {
128
128
  expect(denied).toBeUltimateError('X_FORBIDDEN');
129
129
  });
130
130
 
131
- ${wrapper}('${name.camel} exposes one MCP tool that reads, and never writes', () => {
132
- // Same policy object on both surfaces — an agent cannot reach a different authz path.
133
- expect(target.tool().policy).toBe(target.policy);
134
- expect(target.tool().mutates).toBe(false);
131
+ ${wrapper}('${name.camel} is offered to agents as a read', () => {
132
+ // \`@ultimat3/mcp\`'s \`toolFrom\` serves this block as a read (\`mutates: false\`), through
133
+ // \`.as()\` under this same policy — an agent cannot reach a different authz path.
134
+ expect(target.kind).toBe('query');
135
+ expect(target.mcp?.expose).toBe(true);
135
136
  });
136
137
  ${subscribes ? subscribeTest(name, feature) : ''}`;
137
138
  };
@@ -160,7 +161,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
160
161
 
161
162
  const store = (org: string, title: string) => {
162
163
  const draft = { orgId: org, title, price: { minor: 0, currency: 'USD' } };
163
- const writer = createContext({ actor: testActor('writer', { orgId: org }).actor, clock });
164
+ const writer = ctxOf({ actor: testActor('writer', { orgId: org }).actor, clock });
164
165
  return runWithContext(writer, () => repo.insert(draft));
165
166
  };
166
167
 
@@ -224,7 +225,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
224
225
 
225
226
  const store = (owner: string, title: string) => {
226
227
  const draft = { orgId: owner, title, price: { minor: 0, currency: 'USD' } };
227
- const writer = createContext({ actor: testActor('writer', { orgId: owner }).actor, clock });
228
+ const writer = ctxOf({ actor: testActor('writer', { orgId: owner }).actor, clock });
228
229
  return runWithContext(writer, () => repo.insert(draft));
229
230
  };
230
231
 
@@ -287,7 +288,7 @@ ${shape.storesRow ? storedRowsTest() : ''}`;
287
288
  /** The imports the read cases need, over whatever the file they land in already imports. */
288
289
  const readImports = (shape: ReadShape, testing: readonly string[]): readonly string[] => [
289
290
  ...(shape.storesRow
290
- ? ["import { createContext, frozenClock, runWithContext } from '@ultimat3/core';"]
291
+ ? ["import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';"]
291
292
  : []),
292
293
  `import { ${[...(shape.storesRow ? ['afterEach'] : []), ...testing].join(', ')} } from '@ultimat3/testing';`,
293
294
  ...(shape.storesRow ? [`import { driver } from '${shape.dbModule}';`] : []),
@@ -12,15 +12,17 @@ import { LINE_WIDTH } from './wrap';
12
12
 
13
13
  /**
14
14
  * The page's JS budget — MEASURED, never a ceiling picked to pass. `x build --target static` on a
15
- * fresh `x new` app with `x g resource widget`, Bun 1.4.0 (CI's pin), 2026-10-01: 58,522 B — the
16
- * form island 57,667 + the `visible` hydration runtime 855. 64kb is 65,536: the headroom is Bun's
17
- * own movement on one source — a patch release that keeps core's declared modules measured +3.0 kB
18
- * on the scaffold's theme island, and the tree-shaker flaps by up to 1.1 kB between runs. An app
15
+ * fresh `x new` app with `x g resource widget` under CI's `file:` links, Bun 1.4.2 (CI's pin),
16
+ * 2026-10-05: 59,679 B — the form island 58,824 + the `visible` hydration runtime 855 (57,667 + 855
17
+ * on 1.4.0). 64kb is 65,536: the headroom is Bun's own movement on one source between releases —
18
+ * 1.4.0 → 1.4.2 moved this island +1,157 B, and a patch release that keeps core's declared modules
19
+ * measured +3.0 kB on the scaffold's theme island. Two builds of one source are
20
+ * byte-identical since 1.4.1 (issue #354), so no headroom is held for run-to-run movement. An app
19
21
  * that adds per-document scripts (`navigation: { client: [...] }` charges its router, ~18 kB, to
20
22
  * every `app/` page) is over it on its first build, and `nextSteps` sends the author there.
21
23
  */
22
24
  export const RESOURCE_PAGE_BUDGET = '64kb';
23
- export const RESOURCE_PAGE_MEASURED = '58,522 B';
25
+ export const RESOURCE_PAGE_MEASURED = '59,679 B';
24
26
 
25
27
  /** How the page reaches a string — the same rule `route.ts` states, for the same issue (#249). */
26
28
  const catalogImport = (module: string | undefined): string =>
@@ -186,7 +188,7 @@ const pageTest = (options: ResourcePageOptions): string => {
186
188
  ${sortedImports([
187
189
  catalogImport(options.catalogModule),
188
190
  `import { driver } from '${options.dbModule}';`,
189
- "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
191
+ "import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
190
192
  "import { testActor } from '@ultimat3/policy';",
191
193
  "import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
192
194
  ])}
@@ -209,7 +211,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
209
211
 
210
212
  const store = (title: string) => {
211
213
  const draft = { orgId, title, price: { minor: 1200, currency: 'USD' } };
212
- return runWithContext(createContext({ actor: member, clock }), () => repo.insert(draft));
214
+ return runWithContext(ctxOf({ actor: member, clock }), () => repo.insert(draft));
213
215
  };
214
216
 
215
217
  // One store per process: without this, one test's rows are the next test's fixtures.
@@ -43,7 +43,7 @@ const serviceTest = (
43
43
  ): string => `// The ${feature.kebab} service against the in-memory driver, and its failure, pinned: a code an
44
44
  // agent can match on, a cause naming the row, and a fix that is an instruction.
45
45
  ${sortedImports([
46
- "import { createContext, runWithContext } from '@ultimat3/core';",
46
+ "import { ctxOf, runWithContext } from '@ultimat3/core';",
47
47
  "import { testActor } from '@ultimat3/policy';",
48
48
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
49
49
  `import { driver } from '${dbModule}';`,
@@ -57,7 +57,7 @@ const draft = { orgId, title: 'First', price: { minor: 1200, currency: 'USD' } }
57
57
 
58
58
  /** What a request is to the repo underneath: an actor, whose org every statement runs under. */
59
59
  const inOrg = <T>(run: () => Promise<T>): Promise<T> =>
60
- runWithContext(createContext({ actor: testActor('member', { orgId }).actor }), run);
60
+ runWithContext(ctxOf({ actor: testActor('member', { orgId }).actor }), run);
61
61
 
62
62
  // One store per process: without this, one test's rows are the next test's fixtures.
63
63
  afterEach(() => {
@@ -51,13 +51,13 @@ const DYNAMIC_SEGMENT = /^\[(?<spread>\.\.\.)?(?<name>[^\]]+)\]$/;
51
51
  /**
52
52
  * One URL segment. `kebab()` strips `[` and `]` along with every other non-alphanumeric, so
53
53
  * `x g route "posts/[slug]"` scaffolded `apps/web/app/posts/slug/page.tsx` — a different, STATIC
54
- * route — and reported `ok:true` with no warning. Only the parameter NAME is kebabed now.
54
+ * route — and reported `ok:true` with no warning. A dynamic segment is now kept exactly as the
55
+ * author wrote it: its name is the key the router puts in `params`, so kebabing it renamed
56
+ * `[orderId]` to `[order-id]` behind the author's back — the page read `params.orderId` and got
57
+ * `undefined`.
55
58
  */
56
- export const routeSegment = (part: string): string => {
57
- const match = DYNAMIC_SEGMENT.exec(part);
58
- if (match === null) return kebab(part);
59
- return `[${match.groups?.['spread'] ?? ''}${kebab(match.groups?.['name'] ?? '')}]`;
60
- };
59
+ export const routeSegment = (part: string): string =>
60
+ DYNAMIC_SEGMENT.test(part) ? part : kebab(part);
61
61
 
62
62
  const segmentsOf = (path: string): readonly string[] =>
63
63
  path
@@ -154,11 +154,26 @@ const sampleUrl = (path: string): string =>
154
154
  })
155
155
  .join('/');
156
156
 
157
- /** The params object that URL implies. `{}` was a lie for every dynamic route. */
157
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
158
+
159
+ /**
160
+ * A single-quoted TS string literal — the repo's (and the scaffold's Biome config's) quote style,
161
+ * so the generated file passes `lint` as written. `JSON.stringify` does the escaping.
162
+ */
163
+ const singleQuoted = (value: string): string =>
164
+ `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
165
+
166
+ /** An object key as TS accepts it: bare when it is an identifier, quoted when it is not. */
167
+ const objectKey = (name: string): string => (IDENTIFIER.test(name) ? name : singleQuoted(name));
168
+
169
+ /**
170
+ * The params object that URL implies. `{}` was a lie for every dynamic route; a bare
171
+ * `order-id:` key was a syntax error in the test of every kebab-named param.
172
+ */
158
173
  const paramsLiteral = (path: string): string => {
159
174
  const params = routeParams(path);
160
175
  if (params.length === 0) return '{}';
161
- return `{ ${params.map((name) => `${name}: '${sampleValue(name)}'`).join(', ')} }`;
176
+ return `{ ${params.map((name) => `${objectKey(name)}: ${singleQuoted(sampleValue(name))}`).join(', ')} }`;
162
177
  };
163
178
 
164
179
  const routeTest = (
@@ -41,9 +41,8 @@ contractTest('health is an action exposed over MCP', () => {
41
41
  expect(target.mcp?.expose).toBe(true);
42
42
  });
43
43
 
44
- contractTest('health projects one MCP tool and one OpenAPI operation', () => {
45
- // Same policy object on both surfaces — a public action says so once, not once per surface.
46
- expect(target.tool().policy).toBe(target.policy);
44
+ contractTest('health projects one OpenAPI operation under its export name', () => {
45
+ // The MCP tool is \`@ultimat3/mcp\`'s \`toolFrom\`, served from the \`mcp\` block above.
47
46
  expect(target.openapi().operationId).toBe('health');
48
47
  });
49
48
  `;
@@ -200,7 +200,8 @@ import './global';
200
200
 
201
201
  unitTest('importing shared/global registers the one global stylesheet', () => {
202
202
  const sheets = registeredStylesheets().filter((sheet) => sheet.global);
203
- expect(sheets.map((sheet) => sheet.file.split('/').slice(-3).join('/'))).toEqual([
203
+ // Split on either separator: \`file\` is a host path, \`D:\\…\\global.scss\` on Windows.
204
+ expect(sheets.map((sheet) => sheet.file.split(/[\\\\/]/).slice(-3).join('/'))).toEqual([
204
205
  'web/shared/global.scss',
205
206
  ]);
206
207
  // The custom properties themselves, declared once on the root — what \`tokens.role()\` refers to.