agentic-team 0.0.0-stage → 6.2.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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -2
  3. package/bin/build-template.js +208 -0
  4. package/bin/index.js +1334 -0
  5. package/bin/platforms.js +510 -0
  6. package/package.json +41 -4
  7. package/template/.claude-plugin/plugin.json +10 -0
  8. package/template/.orchestra/README.md +391 -0
  9. package/template/.orchestra/blueprints/README.md +87 -0
  10. package/template/.orchestra/blueprints/api-only.md +46 -0
  11. package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
  12. package/template/.orchestra/blueprints/saas-starter.md +69 -0
  13. package/template/.orchestra/config.yml +293 -0
  14. package/template/.orchestra/roles/orchestrator.md +105 -0
  15. package/template/.orchestra/roles/product-manager.md +439 -0
  16. package/template/.orchestra/scripts/compact-diff.awk +45 -0
  17. package/template/.orchestra/scripts/context.mjs +373 -0
  18. package/template/.orchestra/stacks/README.md +81 -0
  19. package/template/.orchestra/stacks/_lib/common.sh +3095 -0
  20. package/template/.orchestra/stacks/_repo/setup.sh +1113 -0
  21. package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
  22. package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
  23. package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
  24. package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
  25. package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
  26. package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
  27. package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
  28. package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
  29. package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
  30. package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
  31. package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
  32. package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
  33. package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
  34. package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
  35. package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
  36. package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
  37. package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
  38. package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
  39. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
  40. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
  41. package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
  42. package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
  43. package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
  44. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
  45. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
  46. package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
  47. package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
  48. package/template/.orchestra/trackers/github.sh +467 -0
  49. package/template/CLAUDE.md +54 -0
  50. package/template/agents/architect.md +83 -0
  51. package/template/agents/engineer-high.md +17 -0
  52. package/template/agents/engineer-low.md +17 -0
  53. package/template/agents/engineer-max.md +17 -0
  54. package/template/agents/engineer-medium.md +17 -0
  55. package/template/agents/engineer-xhigh.md +17 -0
  56. package/template/agents/lead.md +925 -0
  57. package/template/agents/milestone-reviewer.md +65 -0
  58. package/template/agents/orchestrator.md +105 -0
  59. package/template/agents/product-manager.md +439 -0
  60. package/template/agents/re-reviewer.md +25 -0
  61. package/template/agents/reviewer.md +303 -0
  62. package/template/agents/scaffolder.md +206 -0
  63. package/template/agents/tracker.md +202 -0
  64. package/template/commands/blueprint.md +23 -0
  65. package/template/commands/create-role.md +64 -0
  66. package/template/commands/export.md +45 -0
  67. package/template/commands/help.md +56 -0
  68. package/template/commands/hotfix.md +14 -0
  69. package/template/commands/orchestrator.md +12 -0
  70. package/template/commands/pm.md +18 -0
  71. package/template/commands/rewind.md +63 -0
  72. package/template/commands/setup.md +15 -0
  73. package/template/commands/start.md +29 -0
  74. package/template/commands/status.md +11 -0
  75. package/template/commands/sync.md +76 -0
  76. package/template/commands/verifier.md +53 -0
  77. package/template/hooks/audit-context.sh +166 -0
  78. package/template/hooks/enforce-role.sh +193 -0
  79. package/template/hooks/protect-system-files.sh +123 -0
  80. package/template/hooks/review-rounds.sh +172 -0
  81. package/template/rules/acceptance-check.orchestra.md +11 -0
  82. package/template/rules/analytics-kpi.orchestra.md +56 -0
  83. package/template/rules/code-standards.orchestra.md +35 -0
  84. package/template/rules/comments.orchestra.md +77 -0
  85. package/template/rules/commit-format.orchestra.md +20 -0
  86. package/template/rules/dependency-upgrade.orchestra.md +112 -0
  87. package/template/rules/design-parity.orchestra.md +181 -0
  88. package/template/rules/observability.orchestra.md +121 -0
  89. package/template/rules/performance.orchestra.md +190 -0
  90. package/template/rules/phase-limits.orchestra.md +3 -0
  91. package/template/rules/pull-request.orchestra.md +218 -0
  92. package/template/rules/role-boundaries.orchestra.md +132 -0
  93. package/template/rules/security.orchestra.md +106 -0
  94. package/template/rules/stuck-detection.orchestra.md +25 -0
  95. package/template/rules/testing-standards.orchestra.md +219 -0
  96. package/template/rules/tracker-authority.orchestra.md +198 -0
  97. package/template/rules/verification-gate.orchestra.md +56 -0
  98. package/template/skills/accessibility/SKILL.md +31 -0
  99. package/template/skills/android-testing/SKILL.md +112 -0
  100. package/template/skills/architecture/SKILL.md +175 -0
  101. package/template/skills/auth-setup/SKILL.md +105 -0
  102. package/template/skills/best-practices/SKILL.md +29 -0
  103. package/template/skills/code-optimizer/SKILL.md +27 -0
  104. package/template/skills/core-web-vitals/SKILL.md +32 -0
  105. package/template/skills/crud-api/SKILL.md +32 -0
  106. package/template/skills/debug/SKILL.md +28 -0
  107. package/template/skills/deployment/SKILL.md +104 -0
  108. package/template/skills/error-handling/SKILL.md +154 -0
  109. package/template/skills/frontend-design/SKILL.md +30 -0
  110. package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
  111. package/template/skills/ios-testing/SKILL.md +100 -0
  112. package/template/skills/mobile-testing/SKILL.md +102 -0
  113. package/template/skills/observability/SKILL.md +91 -0
  114. package/template/skills/project-structure/SKILL.md +114 -0
  115. package/template/skills/push-notifications/SKILL.md +146 -0
  116. package/template/skills/react-best-practices/SKILL.md +31 -0
  117. package/template/skills/realtime/SKILL.md +146 -0
  118. package/template/skills/review/SKILL.md +82 -0
  119. package/template/skills/testing/SKILL.md +45 -0
  120. package/template/skills/web-quality-audit/SKILL.md +47 -0
  121. package/template/skills/web-testing/SKILL.md +120 -0
@@ -0,0 +1,3095 @@
1
+ #!/usr/bin/env bash
2
+ # Shared scaffolding helpers. Sourced by every stack's setup.sh.
3
+ # Never hardcode a version here — always resolve from the registry.
4
+
5
+ set -euo pipefail
6
+
7
+ log() { printf ' [+] %s\n' "$*"; }
8
+ warn() { printf ' [!] %s\n' "$*" >&2; }
9
+ die() { printf ' [x] %s\n' "$*" >&2; exit 1; }
10
+
11
+ # Read a JSONC file safely. Comment-stripping by regex is not safe here:
12
+ # "@/*" in a tsconfig paths glob looks like the start of a block comment and
13
+ # the "*/" inside "**/*.ts" looks like its end, so a regex silently deletes
14
+ # the span between them and produces invalid JSON.
15
+ JSONC_READ='
16
+ function readJsonc(file) {
17
+ const raw = require("fs").readFileSync(file, "utf8");
18
+ let out = "", inStr = false, esc = false, i = 0;
19
+ while (i < raw.length) {
20
+ const c = raw[i], n = raw[i + 1];
21
+ if (inStr) {
22
+ out += c;
23
+ if (esc) esc = false;
24
+ else if (c === "\\") esc = true;
25
+ else if (c === "\"") inStr = false;
26
+ i++; continue;
27
+ }
28
+ if (c === "\"") { inStr = true; out += c; i++; continue; }
29
+ if (c === "/" && n === "/") { while (i < raw.length && raw[i] !== "\n") i++; continue; }
30
+ if (c === "/" && n === "*") { i += 2; while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++; i += 2; continue; }
31
+ out += c; i++;
32
+ }
33
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
34
+ }
35
+ '
36
+
37
+ # Latest version of a package, from the registry. Never from memory.
38
+ resolve_latest() {
39
+ local pkg="$1" v
40
+ v="$(npm view "$pkg" version 2>/dev/null)" || die "cannot resolve $pkg"
41
+ [ -n "$v" ] || die "empty version for $pkg"
42
+ printf '%s' "$v"
43
+ }
44
+
45
+ # A create tool can fail and still exit 0 — create-hono drops into a prompt
46
+ # and dies in a non-TTY, leaving an empty directory. Without this the rest of
47
+ # the script happily builds a package.json from nothing and reports success.
48
+ assert_scaffolded() { # dir
49
+ local dir="$1"
50
+ [ -d "$dir" ] || die "scaffold produced no directory at $dir"
51
+ [ -f "$dir/package.json" ] || die "scaffold left no package.json in $dir — the create tool failed"
52
+ [ "$(ls -A "$dir" | wc -l | tr -d ' ')" -gt 2 ] || die "scaffold left $dir essentially empty"
53
+ log "scaffold verified in $dir"
54
+ }
55
+
56
+ # Yarn 4 with the node-modules linker. PnP is not a default worth defending
57
+ # here — it breaks Metro and a good deal of native tooling.
58
+ setup_yarn() {
59
+ corepack enable >/dev/null 2>&1 || warn "corepack enable failed; yarn may resolve to a global install"
60
+ yarn set version stable >/dev/null 2>&1 || die "could not pin a yarn version"
61
+ printf 'nodeLinker: node-modules\n' > .yarnrc.yml
62
+ # The create tool may have left an npm lockfile behind.
63
+ rm -f package-lock.json
64
+
65
+ # Yarn 4 writes .yarn/install-state.gz on every install. A create tool's
66
+ # .gitignore predates yarn 4 and does not mention it, so it lands in the
67
+ # first commit and then churns a binary blob in every diff after it.
68
+ if ! grep -q '^\.yarn/\*' .gitignore 2>/dev/null; then
69
+ cat >> .gitignore <<'GI'
70
+
71
+ # yarn 4
72
+ .yarn/*
73
+ !.yarn/patches
74
+ !.yarn/plugins
75
+ !.yarn/releases
76
+ !.yarn/sdks
77
+ !.yarn/versions
78
+ .pnp.*
79
+ GI
80
+ log "yarn artefacts gitignored"
81
+ fi
82
+ log "yarn $(yarn --version) (node-modules linker)"
83
+ }
84
+
85
+ # package.json edits without npm. `npm pkg set` is npm-only, and without
86
+ # --json it writes every value as a string — which silently disables numeric
87
+ # thresholds.
88
+ pkg_set() { # key.path=value ... ; value is parsed as JSON when it parses
89
+ node -e '
90
+ const fs = require("fs");
91
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
92
+ for (const arg of process.argv.slice(1)) {
93
+ const i = arg.indexOf("=");
94
+ const path = arg.slice(0, i);
95
+ const raw = arg.slice(i + 1);
96
+ let val;
97
+ try { val = JSON.parse(raw); } catch { val = raw; }
98
+ const keys = path.split(".");
99
+ let node = p;
100
+ for (const k of keys.slice(0, -1)) { node[k] = node[k] || {}; node = node[k]; }
101
+ node[keys[keys.length - 1]] = val;
102
+ }
103
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
104
+ ' "$@"
105
+ }
106
+
107
+ # Newest version of a package that is old enough to clear yarn's supply-chain
108
+ # age gate. Yarn 4 quarantines anything published inside npmMinimalAgeGate
109
+ # (24h by default) — the window when a compromised publish is most dangerous.
110
+ # That gate is worth keeping, so resolve around it instead of switching it off.
111
+ resolve_settled() { # pkg
112
+ local pkg="$1" gate
113
+ gate="$(yarn config get npmMinimalAgeGate 2>/dev/null || echo 1440)"
114
+ case "$gate" in ''|*[!0-9]*) gate=1440 ;; esac
115
+ npm view "$pkg" time --json 2>/dev/null | node -e '
116
+ let s = "";
117
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
118
+ const gateMs = Number(process.argv[1]) * 60 * 1000;
119
+ const cutoff = Date.now() - gateMs;
120
+ let times;
121
+ try { times = JSON.parse(s); } catch { process.exit(1); }
122
+ const settled = Object.entries(times)
123
+ .filter(([v]) => !["created", "modified"].includes(v))
124
+ .filter(([v]) => !/-(alpha|beta|rc|next|canary|nightly|preview)/i.test(v))
125
+ .filter(([, t]) => Date.parse(t) < cutoff)
126
+ .map(([v]) => v);
127
+ if (!settled.length) process.exit(1);
128
+ // npm lists versions in publish order; the last settled one is newest.
129
+ process.stdout.write(settled[settled.length - 1]);
130
+ });
131
+ ' "$gate"
132
+ }
133
+
134
+ # Install packages at their current latest. Deliberately NOT pinned to an
135
+ # exact version — the lockfile records what landed, and Dependabot moves it
136
+ # forward. Pass an explicit range (e.g. "tailwindcss@3") only where a real
137
+ # compatibility constraint exists, and say why.
138
+ # Usage: add_latest [--dev] pkg[@range] [pkg...]
139
+ add_latest() {
140
+ local dev="" specs=() pkg bare
141
+ [ "${1:-}" = "--dev" ] && { dev="-D"; shift; }
142
+ for pkg in "$@"; do
143
+ # A leading @ is a scope, not a version — "@vitest/browser" has no range.
144
+ bare="${pkg#@}"
145
+ case "$bare" in
146
+ *@*) specs+=("$pkg"); log "install ${pkg}" ;;
147
+ *) specs+=("${pkg}@latest"); log "install ${pkg}@latest" ;;
148
+ esac
149
+ done
150
+
151
+ if yarn add $dev "${specs[@]}"; then
152
+ return 0
153
+ fi
154
+
155
+ # A fresh publish inside the age gate fails the whole batch. Fall back to
156
+ # the newest settled version per package rather than disabling the gate.
157
+ warn "install failed — retrying against versions past yarn's age gate"
158
+ local retry=() name settled
159
+ for pkg in "$@"; do
160
+ bare="${pkg#@}"
161
+ case "$bare" in
162
+ *@*) retry+=("$pkg"); continue ;;
163
+ esac
164
+ name="$pkg"
165
+ settled="$(resolve_settled "$name" || true)"
166
+ if [ -n "$settled" ]; then
167
+ retry+=("${name}@${settled}")
168
+ log " ${name} -> ${settled} (newest past the gate)"
169
+ else
170
+ retry+=("${name}@latest")
171
+ fi
172
+ done
173
+ yarn add $dev "${retry[@]}" || die "could not install: ${retry[*]}"
174
+ }
175
+
176
+ # Expo pins React and React Native to what its SDK was built against, so a
177
+ # plain "@latest" resolves versions the SDK rejects. `expo install` picks the
178
+ # SDK-compatible version and falls back to latest when there is no pin, which
179
+ # is what "current versions" actually means inside Expo.
180
+ expo_install() {
181
+ local dev=""
182
+ [ "${1:-}" = "--dev" ] && { dev="--dev"; shift; }
183
+ log "expo install $*"
184
+ yarn expo install $dev "$@"
185
+ }
186
+
187
+ # TypeScript with the strictness the conventions require.
188
+ setup_typescript() {
189
+ add_latest --dev typescript
190
+ node -e "$JSONC_READ"'
191
+ const fs = require("fs");
192
+ const f = "tsconfig.json";
193
+ const cfg = fs.existsSync(f) ? readJsonc(f) : {};
194
+ cfg.compilerOptions = {
195
+ ...(cfg.compilerOptions || {}),
196
+ strict: true,
197
+ noUncheckedIndexedAccess: true,
198
+ noUnusedLocals: true,
199
+ noUnusedParameters: true,
200
+ };
201
+ fs.writeFileSync(f, JSON.stringify(cfg, null, 2) + "\n");
202
+ '
203
+ # A solution-style config delegates to referenced files; flags set on it
204
+ # apply to nothing. Apply them where the sources actually are.
205
+ node -e "$JSONC_READ"'
206
+ const fs = require("fs");
207
+ const strict = {
208
+ strict: true,
209
+ noUncheckedIndexedAccess: true,
210
+ noUnusedLocals: true,
211
+ noUnusedParameters: true,
212
+ };
213
+ for (const f of ["tsconfig.app.json", "tsconfig.node.json"]) {
214
+ if (!fs.existsSync(f)) continue;
215
+ const cfg = readJsonc(f);
216
+ cfg.compilerOptions = { ...(cfg.compilerOptions || {}), ...strict };
217
+ fs.writeFileSync(f, JSON.stringify(cfg, null, 2) + "\n");
218
+ console.log(" [+] strict applied to " + f);
219
+ }
220
+ '
221
+ log "typescript: strict + noUncheckedIndexedAccess"
222
+ }
223
+
224
+ # Biome replaces ESLint and Prettier entirely. Remove whatever the
225
+ # create tool installed, so two formatters never fight.
226
+ setup_biome() {
227
+ add_latest --dev @biomejs/biome
228
+ npx biome init >/dev/null 2>&1 || true
229
+ # Whatever linter the create tool shipped goes — two linters means two sets
230
+ # of rules disagreeing. create-vite ships oxlint now, older ones eslint.
231
+ node -e '
232
+ const fs = require("fs");
233
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
234
+ for (const d of ["eslint","prettier","eslint-config-prettier","oxlint",
235
+ "eslint-plugin-react-hooks","eslint-plugin-react-refresh",
236
+ "typescript-eslint","@eslint/js"]) {
237
+ if (p.devDependencies) delete p.devDependencies[d];
238
+ if (p.dependencies) delete p.dependencies[d];
239
+ }
240
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
241
+ '
242
+ rm -f .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json eslint.config.js .oxlintrc.json \
243
+ .prettierrc .prettierrc.json prettier.config.js
244
+ pkg_set scripts.lint="biome check ." scripts.format="biome format --write ."
245
+
246
+ # Without tailwindDirectives biome hard-fails parsing `@theme`, and without
247
+ # the excludes it lints build output and static assets.
248
+ node -e "$JSONC_READ"'
249
+ const fs = require("fs");
250
+ const f = ["biome.json", "biome.jsonc"].find((x) => fs.existsSync(x));
251
+ if (!f) process.exit(0);
252
+ const c = readJsonc(f);
253
+ c.css = { ...(c.css || {}), parser: { ...((c.css || {}).parser || {}), tailwindDirectives: true } };
254
+ // Union, not replace: a create tool writes its own excludes (".next" for
255
+ // next, "node_modules" everywhere) and dropping them makes the first
256
+ // lint run walk build output.
257
+ // biome treats "!dist" and "!dist/**" as the same pattern, so compare
258
+ // them that way or the merged list carries both.
259
+ const key = (g) => g.replace(/\/\*\*$/, "");
260
+ const merged = new Map();
261
+ for (const g of [...((c.files || {}).includes || ["**"]),
262
+ "**", "!public/**", "!dist/**", "!coverage/**", "!build/**"]) {
263
+ if (!merged.has(key(g))) merged.set(key(g), g);
264
+ }
265
+ c.files = { ...(c.files || {}), includes: [...merged.values()] };
266
+ fs.writeFileSync(f, JSON.stringify(c, null, 2) + "\n");
267
+ '
268
+ npx biome check --write . >/dev/null 2>&1 || true
269
+ log "biome installed, other linters removed, scaffold formatted"
270
+ }
271
+
272
+ # Component tests in a real browser, driven by Playwright.
273
+ # Playwright's own CT package is still experimental — this is not.
274
+ setup_vitest_browser() {
275
+ # vite is a REQUIRED peer of vitest (peerDependenciesMeta.vite.optional=false).
276
+ # create-vite supplies it; a Next.js app does not, and without it the runner
277
+ # dies at startup on "Cannot find package 'vite'".
278
+ add_latest --dev vite vitest @vitest/browser @vitest/browser-playwright playwright vitest-browser-react
279
+ npx playwright install chromium >/dev/null 2>&1 || warn "playwright browser install failed"
280
+ pkg_set scripts.test="vitest run --passWithNoTests" scripts.test:watch="vitest"
281
+
282
+ # Browser mode has to be configured, not just installed. Without this block
283
+ # the first component test fails on `document is not defined`.
284
+ if [ ! -f vitest.config.ts ]; then
285
+ cat > vitest.config.ts <<'VITEST'
286
+ import { defineConfig } from "vitest/config";
287
+ import { playwright } from "@vitest/browser-playwright";
288
+
289
+ export default defineConfig({
290
+ test: {
291
+ browser: {
292
+ enabled: true,
293
+ // A factory, not the string "playwright" — the string form fails at
294
+ // startup on vitest 4.
295
+ provider: playwright(),
296
+ headless: true,
297
+ instances: [{ browser: "chromium" }],
298
+ },
299
+ },
300
+ });
301
+ VITEST
302
+ log "vitest browser mode configured"
303
+ fi
304
+ log "vitest + browser mode (playwright driver)"
305
+ }
306
+
307
+ # True end-to-end, separate from component tests.
308
+ #
309
+ # Component tests render one component in a browser. This starts the real
310
+ # app — the built one, not the dev server — and drives it. It is the only
311
+ # layer that sees a broken route, a bad environment variable, or a bundle
312
+ # that does not boot.
313
+ #
314
+ # Usage: setup_playwright_e2e <server-command> <url>
315
+ setup_playwright_e2e() {
316
+ local server_cmd="${1:?server command required}" url="${2:?url required}"
317
+ add_latest --dev @playwright/test
318
+ pkg_set scripts.test:e2e="playwright test"
319
+
320
+ if [ ! -f playwright.config.ts ]; then
321
+ cat > playwright.config.ts <<PW
322
+ import { defineConfig, devices } from "@playwright/test";
323
+
324
+ export default defineConfig({
325
+ testDir: "./e2e",
326
+ // A flaky e2e suite is worse than none — it teaches people to re-run.
327
+ // Retry only on CI, where the cause is usually the runner and not the app.
328
+ retries: process.env.CI ? 2 : 0,
329
+ forbidOnly: !!process.env.CI,
330
+ reporter: process.env.CI ? "github" : "list",
331
+ use: {
332
+ baseURL: "${url}",
333
+ trace: "on-first-retry",
334
+ screenshot: "only-on-failure",
335
+ },
336
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
337
+ // The built app, not the dev server. A dev server hides exactly the
338
+ // failures this layer exists to catch: a bad production bundle, a missing
339
+ // environment variable, a route that only resolves in development.
340
+ webServer: {
341
+ command: "${server_cmd}",
342
+ url: "${url}",
343
+ reuseExistingServer: !process.env.CI,
344
+ timeout: 180_000,
345
+ },
346
+ });
347
+ PW
348
+ log "playwright config (builds and serves the app)"
349
+ fi
350
+
351
+ # vitest's default include matches **/*.spec.ts, so it picks up the
352
+ # playwright specs, tries to bundle @playwright/test, and dies on its
353
+ # native fsevents dependency. The function that creates e2e/ is the one
354
+ # that has to keep the unit runner out of it.
355
+ if [ -f vitest.config.ts ] && ! grep -q "configDefaults" vitest.config.ts; then
356
+ node -e '
357
+ const fs = require("fs");
358
+ const f = "vitest.config.ts";
359
+ let s = fs.readFileSync(f, "utf8");
360
+ s = s.replace(
361
+ /import \{ defineConfig \} from "vitest\/config";/,
362
+ `import { configDefaults, defineConfig } from "vitest/config";`
363
+ );
364
+ const block = [
365
+ " // e2e/ belongs to playwright. vitest would otherwise try to bundle",
366
+ " // @playwright/test and fail on its native dependency.",
367
+ " exclude: [...configDefaults.exclude, \"e2e/**\"],",
368
+ ].join("\n");
369
+ const i = s.indexOf("test: {");
370
+ if (i === -1) process.exit(0);
371
+ s = s.slice(0, i + "test: {".length) + "\n" + block + s.slice(i + "test: {".length);
372
+ fs.writeFileSync(f, s);
373
+ ' || warn "could not exclude e2e/ from vitest — do it by hand"
374
+ log "vitest excludes e2e/"
375
+ fi
376
+
377
+ mkdir -p e2e
378
+ if [ ! -f e2e/smoke.spec.ts ]; then
379
+ cat > e2e/smoke.spec.ts <<'SPEC'
380
+ import { expect, test } from "@playwright/test";
381
+
382
+ /**
383
+ * The highest-value single check on the web: the built app boots and renders.
384
+ *
385
+ * Nothing else in the gate sees this. Component tests mount one component in
386
+ * isolation, and a typecheck never loads the bundler — so a broken route, a
387
+ * missing environment variable or a bundle that throws on load all pass
388
+ * every other check and fail here.
389
+ */
390
+ test("the app boots and renders something", async ({ page }) => {
391
+ const errors: string[] = [];
392
+ page.on("pageerror", (e) => errors.push(e.message));
393
+
394
+ const response = await page.goto("/");
395
+ expect(response?.status(), "the server answered").toBeLessThan(400);
396
+
397
+ // Not a screenshot and not a text match — either would need editing the
398
+ // day the copy changes. This asserts the document actually rendered.
399
+ await expect(page.locator("body")).toBeVisible();
400
+ expect(errors, "the page threw while loading").toEqual([]);
401
+ });
402
+ SPEC
403
+ log "e2e/smoke.spec.ts"
404
+ fi
405
+
406
+ cat > e2e/README.md <<'MD'
407
+ # End-to-end
408
+
409
+ Few, and about journeys rather than components. Each one costs a build and a
410
+ browser, and a suite nobody trusts is worse than no suite at all.
411
+
412
+ What belongs here:
413
+
414
+ - the app boots (`smoke.spec.ts` — already written)
415
+ - sign in, and stay signed in across a reload
416
+ - the one flow the product exists for, start to finish
417
+ - the failure path of that flow — the funnel that only records success
418
+ cannot tell you why people left
419
+
420
+ What does not: anything a component test can answer, and anything asserted
421
+ by a screenshot of text that will change.
422
+
423
+ Query by role, never by test id — see `.claude/skills/web-testing/SKILL.md`.
424
+ MD
425
+ log "playwright e2e (built app, smoke test written)"
426
+ }
427
+
428
+ # Pin Node so CI and local agree.
429
+ setup_node_pin() {
430
+ local v="${1:-$(node -v | sed 's/^v//')}"
431
+ printf '%s\n' "$v" > .node-version
432
+ pkg_set "engines.node=>=${v%%.*}"
433
+ log "node pinned to $v"
434
+ }
435
+
436
+ # Coverage, as four gates rather than one number.
437
+ #
438
+ # A single global percentage stops measuring test quality the moment it
439
+ # becomes a gate — it measures test quantity instead, and it counts a design
440
+ # token file the same as the module wrapping the database driver.
441
+ #
442
+ # So: start the global thresholds at zero with autoUpdate on. The first run
443
+ # writes the real numbers into this file and every run after it fails on a
444
+ # drop. That is a ratchet, and it needs no arbitrary starting number.
445
+ # The remaining gates — branch coverage of the changed lines, and 100% on the
446
+ # modules that wrap a third-party library — live in scripts/diff-coverage.mjs
447
+ # and in `thresholds` glob keys respectively.
448
+ #
449
+ # Additive on purpose: setup_vitest_browser may already have written a
450
+ # config, and silently skipping would leave the gates unset while still
451
+ # logging success.
452
+ setup_coverage() {
453
+ # The reporters below write coverage/ into the app. create-next-app ignores
454
+ # it; create-hono and create-expo-app do not, so without this the first
455
+ # `git add -A` commits an HTML coverage report.
456
+ if ! grep -qE '^/?coverage' .gitignore 2>/dev/null; then
457
+ printf '\n# coverage output\ncoverage/\n' >> .gitignore
458
+ fi
459
+
460
+ add_latest --dev @vitest/coverage-v8
461
+
462
+ # Three steps, one command. The ratchet rewrites vitest.config.ts whenever
463
+ # coverage improves; the cap holds that floor below ratchet_ceiling, because
464
+ # autoUpdate otherwise records 100 on a small fully-covered app and turns the
465
+ # ratchet into a target nobody set; biome reformats what both of them wrote,
466
+ # since vitest writes with its own formatting and the next lint run rejects
467
+ # it. Leaving any of them out is a landmine for whoever runs lint next.
468
+ write_coverage_cap_script
469
+ local cov_root cap_rel
470
+ cov_root="$(git rev-parse --show-toplevel)"
471
+ cap_rel="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$cov_root/scripts/coverage-cap.mjs")"
472
+ pkg_set scripts.test:coverage="vitest run --coverage --passWithNoTests && node ${cap_rel} && biome check --write vitest.config.ts"
473
+
474
+ if [ ! -f vitest.config.ts ]; then
475
+ cat > vitest.config.ts <<'VITEST'
476
+ import { defineConfig } from "vitest/config";
477
+
478
+ export default defineConfig({
479
+ test: {},
480
+ });
481
+ VITEST
482
+ fi
483
+
484
+ node -e '
485
+ const fs = require("fs");
486
+ const f = "vitest.config.ts";
487
+ let s = fs.readFileSync(f, "utf8");
488
+ if (s.includes("coverage:")) process.exit(0);
489
+ const block = [
490
+ " coverage: {",
491
+ " provider: \"v8\",",
492
+ " // json-summary and json feed scripts/diff-coverage.mjs; lcov is",
493
+ " // what every external reporter reads.",
494
+ " reporter: [\"text\", \"json\", \"json-summary\", \"lcov\"],",
495
+ " // Every source file, not only the ones a test happened to load.",
496
+ " // Without this a file nobody imports is invisible, and the",
497
+ " // percentage flatters itself by measuring only what was already",
498
+ " // being tested. Anything that genuinely should not count belongs",
499
+ " // in exclude below — a deliberate, reviewable edit rather than a",
500
+ " // test written to move a number.",
501
+ " include: [\"src/**/*.{ts,tsx}\"],",
502
+ " // Counted against nothing: these move the number without saying",
503
+ " // anything about whether the code was checked.",
504
+ " exclude: [",
505
+ " \"**/*.d.ts\",",
506
+ " \"**/*.test.*\",",
507
+ " // Workbench catalogue entries, not shipped logic.",
508
+ " \"**/*.fixture.*\",",
509
+ " \"**/*.config.*\",",
510
+ " \"**/index.ts\",",
511
+ " \"**/design/tokens.*\",",
512
+ " \"**/.{idea,git,cache,output,temp}/**\",",
513
+ " ],",
514
+ " thresholds: {",
515
+ " // The ratchet. Starts at zero and is rewritten upward by vitest",
516
+ " // whenever coverage improves; a drop fails the run. Commit the",
517
+ " // updated numbers with the change that earned them.",
518
+ " autoUpdate: true,",
519
+ " lines: 0,",
520
+ " functions: 0,",
521
+ " branches: 0,",
522
+ " statements: 0,",
523
+ " // Seams — every module that wraps a third-party library. These",
524
+ " // are what make a dependency bump turn the suite red, they are",
525
+ " // small, and so 100% here is meaningful where a global 100% is",
526
+ " // not.",
527
+ " //",
528
+ " // Live from day one, because the location is a convention rather",
529
+ " // than something discovered later: anything wrapping a third",
530
+ " // party lives in src/integrations/<provider>/. An empty or",
531
+ " // absent directory matches nothing and passes.",
532
+ " //",
533
+ " // This asks that the code be exercised, not that a test file sit",
534
+ " // beside it. An integration test that drives the wrapper through",
535
+ " // a faked HTTP boundary satisfies it — no second test, no",
536
+ " // duplicate assertion.",
537
+ " \"src/integrations/**\": { 100: true },",
538
+ " },",
539
+ " },",
540
+ ].join("\n");
541
+ const i = s.indexOf("test: {");
542
+ if (i === -1) {
543
+ console.error("no test block in vitest.config.ts — coverage not applied");
544
+ process.exit(1);
545
+ }
546
+ s = s.slice(0, i + "test: {".length) + "\n" + block + s.slice(i + "test: {".length);
547
+ fs.writeFileSync(f, s);
548
+ ' || die "failed to write the coverage block"
549
+
550
+ write_diff_coverage_script
551
+ local root rel
552
+ root="$(git rev-parse --show-toplevel)"
553
+ rel="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$root/scripts/diff-coverage.mjs")"
554
+ pkg_set scripts.test:coverage:diff="node $rel"
555
+ log "coverage: ratchet + diff gate (seam globs are added per app)"
556
+ }
557
+
558
+ setup_typecheck_script() {
559
+ # -b is required: create-vite ships a solution-style tsconfig (files: [] +
560
+ # references), and plain `tsc --noEmit` checks nothing at all against it.
561
+ # `tsc -b --noEmit` is correct for both solution and flat configs.
562
+ pkg_set scripts.typecheck="tsc -b --noEmit"
563
+ log "typecheck script"
564
+ }
565
+
566
+ # The Orchestra verification gate assumes these exist. Fail loudly if not.
567
+ assert_verification_scripts() {
568
+ local missing=()
569
+ # build is in here because the rule argues hardest for it and nothing else
570
+ # checked it existed. An app that genuinely cannot be built does not reach
571
+ # this function — the native stacks verify through their own verify.sh.
572
+ for s in typecheck test lint "test:coverage" build; do
573
+ node -e 'const s=require("./package.json").scripts||{};process.exit(s[process.argv[1]]?0:1)' "$s" || missing+=("$s")
574
+ done
575
+ [ ${#missing[@]} -eq 0 ] || die "missing scripts: ${missing[*]}"
576
+ log "verification scripts present"
577
+ }
578
+
579
+ # ── Repo-wide verification gate ───────────────────────────────────────────
580
+ # Runs from the git root and covers every app, whatever language it is in.
581
+ # Detection is by marker file, so adding a Python or Go app needs no edit
582
+ # here beyond its own branch.
583
+ write_verify_script() {
584
+ local root; root="$(git rev-parse --show-toplevel)"
585
+ mkdir -p "$root/scripts"
586
+ cat > "$root/scripts/verify.sh" <<'VERIFY'
587
+ #!/usr/bin/env bash
588
+ # Repo-wide verification. Every app, in order, stop at the first failure.
589
+ # This is the gate the pre-commit hook runs. Keep it fast enough to survive.
590
+ set -euo pipefail
591
+
592
+ ROOT="$(git rev-parse --show-toplevel)"
593
+ cd "$ROOT"
594
+
595
+ fail() { printf '\n [x] %s failed in %s\n' "$1" "$2" >&2; exit 1; }
596
+
597
+ # Secrets first, and on what is about to be committed rather than the whole
598
+ # tree — a leaked credential is compromised the moment it is pushed, and
599
+ # rotating it is the only fix. CI scans the full history; this catches it one
600
+ # step earlier, while it is still cheap.
601
+ if command -v gitleaks >/dev/null 2>&1; then
602
+ printf ' \u2192 secrets\n'
603
+ gitleaks protect --staged --no-banner --redact || fail gitleaks secrets
604
+ else
605
+ printf ' \u2192 secrets (gitleaks not installed \u2014 CI still scans; brew install gitleaks)\n'
606
+ fi
607
+
608
+ # The checks that used to be prose in the rules. See scripts/policy-check.mjs.
609
+ if [ -f "$ROOT/scripts/policy-check.mjs" ]; then
610
+ printf ' \u2192 policy\n'
611
+ node "$ROOT/scripts/policy-check.mjs" || fail policy repo
612
+ fi
613
+
614
+ verify_one() {
615
+ local dir="$1" name; name="$(basename "$dir")"
616
+ printf ' → %s\n' "$name"
617
+ (
618
+ cd "$dir"
619
+ if [ -f package.json ]; then
620
+ # `yarn run`, not `yarn --silent`: yarn 4 parses a leading --silent as
621
+ # the command name and exits 1 with a syntax error, so every step of
622
+ # this gate failed for a reason that had nothing to do with the code.
623
+ yarn run --silent typecheck || fail typecheck "$name"
624
+ yarn run --silent lint || fail lint "$name"
625
+ yarn run --silent test || fail test "$name"
626
+ elif [ -f gradlew ]; then
627
+ ./gradlew --quiet lint test || fail gradle "$name"
628
+ elif [ -f project.yml ]; then
629
+ # Whichever simulator this Xcode actually ships, not a model name
630
+ # written down once — that fails on any machine with a different set,
631
+ # and the error names a device rather than the real problem.
632
+ #
633
+ # `generic/platform=iOS Simulator` is not an option here: it is valid
634
+ # for build and rejected by test, which needs somewhere to run.
635
+ sim="$(xcrun simctl list devices available -j 2>/dev/null \
636
+ | node -e '
637
+ let s = "";
638
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
639
+ try {
640
+ const all = Object.values(JSON.parse(s).devices).flat();
641
+ const pick = all.find((d) => /^iPhone/.test(d.name)) || all[0];
642
+ process.stdout.write(pick ? pick.udid : "");
643
+ } catch {}
644
+ });
645
+ ' 2>/dev/null)"
646
+ [ -n "$sim" ] || fail "no iOS simulator installed" "$name"
647
+ xcodebuild test -quiet -scheme "$name" -destination "id=$sim" \
648
+ || fail xcodebuild "$name"
649
+ elif [ -f pyproject.toml ]; then
650
+ ruff check . || fail ruff "$name"
651
+ mypy . || fail mypy "$name"
652
+ pytest -q || fail pytest "$name"
653
+ else
654
+ printf ' (no known marker — skipped)\n'
655
+ fi
656
+ )
657
+ }
658
+
659
+ # A workspaces root has a package.json but no typecheck/test/lint scripts of
660
+ # its own. Treating it as a target rejects every commit in the repo until
661
+ # somebody hand-edits this file.
662
+ has_verify_scripts() {
663
+ [ -f "$1/package.json" ] || return 1
664
+ node -e '
665
+ const p = require(process.argv[1] + "/package.json");
666
+ const s = p.scripts || {};
667
+ process.exit(["typecheck","test","lint"].some((k) => s[k]) ? 0 : 1);
668
+ ' "$1" 2>/dev/null
669
+ }
670
+
671
+ targets=()
672
+ if has_verify_scripts "$ROOT" || [ -f "$ROOT/pyproject.toml" ]; then
673
+ targets+=("$ROOT")
674
+ fi
675
+ for d in apps/*/ packages/*/; do
676
+ [ -d "$d" ] || continue
677
+ targets+=("$ROOT/${d%/}")
678
+ done
679
+
680
+ [ ${#targets[@]} -gt 0 ] || { echo " nothing to verify"; exit 0; }
681
+ for t in "${targets[@]}"; do verify_one "$t"; done
682
+ printf '\n [+] all checks passed\n'
683
+ VERIFY
684
+ chmod +x "$root/scripts/verify.sh"
685
+ log "wrote scripts/verify.sh"
686
+ }
687
+
688
+ # Pre-commit gate. Uses husky when the repo root has a package.json;
689
+ # otherwise git's own core.hooksPath — which is the same mechanism husky
690
+ # configures, minus the dependency it cannot install without a root manifest.
691
+ install_pre_commit_gate() {
692
+ local root; root="$(git rev-parse --show-toplevel)"
693
+ write_verify_script
694
+ write_policy_check_script
695
+
696
+ # git-native rather than husky. husky wires itself from the `prepare`
697
+ # lifecycle, and yarn — which every stack here installs — does not run
698
+ # `prepare` for the root project, so the hook file was written and the
699
+ # hook never fired. An installed hook that never runs is worse than none,
700
+ # because it is believed. core.hooksPath needs no dependency and no
701
+ # lifecycle script, and it behaves the same under every package manager.
702
+ mkdir -p "$root/.githooks"
703
+ printf '#!/usr/bin/env sh\nbash scripts/verify.sh\n' > "$root/.githooks/pre-commit"
704
+ chmod +x "$root/.githooks/pre-commit"
705
+ ( cd "$root" && git config core.hooksPath .githooks )
706
+ log "pre-commit gate installed (core.hooksPath)"
707
+ }
708
+
709
+
710
+ # ── Freshness ─────────────────────────────────────────────────────────────
711
+ # Everything below exists because a repo scaffolded today was born stale.
712
+
713
+ # The newest published version of each package, filtered the way the rest of
714
+ # this file filters: no prereleases, and nothing so fresh that yarn's
715
+ # supply-chain age gate will quarantine it. One request per package, in
716
+ # parallel, straight to the registry — `npm view` in a loop is minutes.
717
+ # Prints "name<TAB>version" per line; a package it cannot resolve is omitted
718
+ # rather than guessed.
719
+ registry_latest() { # pkg ...
720
+ [ $# -gt 0 ] || return 0
721
+ local gate
722
+ gate="$(yarn config get npmMinimalAgeGate 2>/dev/null || echo 1440)"
723
+ case "$gate" in ''|*[!0-9]*) gate=1440 ;; esac
724
+ GATE_MINUTES="$gate" node -e '
725
+ const names = process.argv.slice(1);
726
+ const cutoff = Date.now() - Number(process.env.GATE_MINUTES) * 60 * 1000;
727
+ const pre = /-(alpha|beta|rc|next|canary|nightly|preview|experimental)/i;
728
+ const cmp = (a, b) => {
729
+ const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
730
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
731
+ return 0;
732
+ };
733
+ Promise.all(names.map(async (name) => {
734
+ try {
735
+ const res = await fetch("https://registry.npmjs.org/" + name.replace("/", "%2f"));
736
+ if (!res.ok) return null;
737
+ const doc = await res.json();
738
+ const settled = Object.entries(doc.time || {})
739
+ .filter(([v]) => !["created", "modified"].includes(v))
740
+ .filter(([v]) => !pre.test(v) && /^\d+\.\d+\.\d+$/.test(v))
741
+ .filter(([, t]) => Date.parse(t) < cutoff)
742
+ .map(([v]) => v)
743
+ .sort(cmp);
744
+ const newest = settled[settled.length - 1];
745
+ return newest ? name + "\t" + newest : null;
746
+ } catch { return null; }
747
+ })).then((rows) => process.stdout.write(rows.filter(Boolean).join("\n") + "\n"));
748
+ ' "$@"
749
+ }
750
+
751
+ # A create tool bakes its own dependency versions into the package.json it
752
+ # generates, and those were current on the day that template was published —
753
+ # create-next-app ships "@types/node": "^20", create-expo-app ships jest 29.
754
+ # Nothing else in these scripts touches them, so the repo is born stale and
755
+ # dependabot opens a PR against a project that is an hour old.
756
+ #
757
+ # Bring every range the template left behind to what the registry serves now.
758
+ # The prefix style is preserved deliberately: an exact pin stays exact, a
759
+ # caret stays a caret. This changes the version, not the template's policy.
760
+ #
761
+ # Packages a framework pins on purpose are passed in as exclusions and a
762
+ # trailing * matches a prefix. Where the framework — not the registry — is
763
+ # the authority for the whole app, use assert_expo_sdk_aligned instead.
764
+ #
765
+ # Usage: refresh_scaffold_deps [pkg-to-skip ...]
766
+ refresh_scaffold_deps() {
767
+ local skips="$*" names versions
768
+ names="$(SKIPS="$skips" node -e '
769
+ const p = require(process.cwd() + "/package.json");
770
+ const skips = (process.env.SKIPS || "").split(/\s+/).filter(Boolean);
771
+ const skipped = (n) => skips.some((s) => (s.endsWith("*") ? n.startsWith(s.slice(0, -1)) : n === s));
772
+ const out = [];
773
+ for (const field of ["dependencies", "devDependencies"]) {
774
+ for (const [name, range] of Object.entries(p[field] || {})) {
775
+ // Anything that is not a plain registry range belongs to someone
776
+ // else: workspace:, file:, link:, npm:, a git url, or a bare "*".
777
+ if (!/^[\^~>=]*\d/.test(range)) continue;
778
+ if (skipped(name)) continue;
779
+ out.push(name);
780
+ }
781
+ }
782
+ process.stdout.write(out.join(" "));
783
+ ')"
784
+ [ -n "$names" ] || { log "no template-shipped ranges to refresh"; return 0; }
785
+
786
+ # Read into an array rather than relying on unquoted word splitting — this
787
+ # file is sourced, and not every shell that sources it splits on IFS.
788
+ local pkgs=()
789
+ read -ra pkgs <<< "$names"
790
+ log "checking ${#pkgs[@]} template-shipped ranges against the registry"
791
+ versions="$(registry_latest "${pkgs[@]}")" || { warn "registry unreachable — ranges left as the template wrote them"; return 0; }
792
+
793
+ cp package.json .package.json.pre-refresh
794
+ LATEST="$versions" node -e '
795
+ const fs = require("fs");
796
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
797
+ const latest = new Map(
798
+ (process.env.LATEST || "").split("\n").filter(Boolean).map((l) => l.split("\t"))
799
+ );
800
+ let changed = 0;
801
+ for (const field of ["dependencies", "devDependencies"]) {
802
+ for (const [name, range] of Object.entries(p[field] || {})) {
803
+ const next = latest.get(name);
804
+ if (!next) continue;
805
+ const prefix = (range.match(/^[\^~]/) || [""])[0];
806
+ const current = range.replace(/^[\^~>=]*/, "");
807
+ if (current === next) continue;
808
+ const majorFrom = current.split(".")[0], majorTo = next.split(".")[0];
809
+ p[field][name] = prefix + next;
810
+ changed++;
811
+ console.log(
812
+ " [+] " + name + " " + range + " -> " + prefix + next +
813
+ (majorFrom !== majorTo ? " (major)" : "")
814
+ );
815
+ }
816
+ }
817
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
818
+ process.exit(changed ? 0 : 3);
819
+ ' || { rm -f .package.json.pre-refresh; log "every range was already current"; return 0; }
820
+
821
+ if ! yarn install; then
822
+ warn "install failed after refreshing ranges — restoring what the template wrote"
823
+ mv .package.json.pre-refresh package.json
824
+ yarn install || die "could not restore the template's dependency ranges"
825
+ return 0
826
+ fi
827
+
828
+ # A major move can compile fine and still be wrong, but it cannot be right
829
+ # if it does not compile. Check before leaving it in place.
830
+ if node -e 'const s=require("./package.json").scripts||{};process.exit(s.typecheck?0:1)'; then
831
+ if ! yarn typecheck; then
832
+ warn "typecheck failed after refreshing ranges — restoring what the template wrote"
833
+ warn "the versions above are current; something in the scaffold does not accept them"
834
+ mv .package.json.pre-refresh package.json
835
+ yarn install || die "could not restore the template's dependency ranges"
836
+ return 0
837
+ fi
838
+ fi
839
+ rm -f .package.json.pre-refresh
840
+ # Refreshing biome's own version is what makes its config stale — the
841
+ # $schema pin and any option renamed since the template shipped. Its
842
+ # migrate command is the thing that knows what changed.
843
+ yarn biome migrate --write >/dev/null 2>&1 || true
844
+ # JSON.stringify writes two spaces; the repo may be formatted with tabs.
845
+ # Without this the very next lint run fails on a file this function wrote.
846
+ yarn biome check --write package.json >/dev/null 2>&1 || true
847
+ log "template-shipped ranges refreshed"
848
+ }
849
+
850
+ # Expo is the one place where the registry is not the authority. The SDK is
851
+ # built against specific versions of react, react-native and every native
852
+ # module, and `expo install` resolves against that table rather than latest.
853
+ # Installing those with add_latest produces a tree the SDK rejects — usually
854
+ # at runtime, on a device, long after the scaffold reported success.
855
+ #
856
+ # So: install through expo_install, then make Expo itself confirm the result.
857
+ assert_expo_sdk_aligned() {
858
+ log "realigning SDK-managed packages"
859
+
860
+ # Clear whatever a previous run deferred, so a package held back by the age
861
+ # gate last week is reconsidered today rather than excluded forever.
862
+ node -e '
863
+ const fs = require("fs");
864
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
865
+ const prev = p.orchestra?.ageGateDeferred || [];
866
+ if (!prev.length) process.exit(0);
867
+ const ex = p.expo?.install?.exclude || [];
868
+ if (p.expo?.install) p.expo.install.exclude = ex.filter((n) => !prev.includes(n));
869
+ delete p.orchestra.ageGateDeferred;
870
+ if (!Object.keys(p.orchestra).length) delete p.orchestra;
871
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
872
+ '
873
+
874
+ # --fix aborts the whole batch when one package cannot be installed, so a
875
+ # failure here is a starting point rather than a verdict.
876
+ yarn expo install --fix >/dev/null 2>&1 || warn "expo install --fix did not complete — resolving what is left"
877
+
878
+ local deferred=() attempt
879
+ for attempt in 1 2; do
880
+ local out lines=() line name installed expected settled
881
+ out="$(yarn expo install --check 2>&1 || true)"
882
+ # " expo@57.0.14 - expected version: ~57.0.15"
883
+ while IFS= read -r line; do
884
+ [ -n "$line" ] && lines+=("$line")
885
+ done < <(printf '%s\n' "$out" | grep -E '^[[:space:]]+\S+@\S+ - expected version: ' || true)
886
+
887
+ [ ${#lines[@]} -gt 0 ] || { log "every SDK-managed package matches the installed Expo SDK"; return 0; }
888
+ [ "$attempt" = 1 ] || break
889
+
890
+ for line in "${lines[@]}"; do
891
+ name="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+(.+)@[^@]+ - expected version: .*/\1/')"
892
+ installed="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+.+@([^@]+) - expected version: .*/\1/')"
893
+ expected="$(printf '%s' "$line" | sed -E 's/.* - expected version: //')"
894
+ settled="$(settled_near "$name" "$expected" || true)"
895
+ [ -n "$settled" ] || die "$name must be $expected for this SDK and no such version exists"
896
+ if [ "$settled" = "$installed" ]; then
897
+ # The SDK wants a version published inside yarn's age gate. This is
898
+ # the newest one outside it, so it is already the right answer.
899
+ deferred+=("$name")
900
+ warn "deferred: ${name}@${installed} (SDK wants ${expected}, still inside the age gate)"
901
+ else
902
+ log " $name $installed -> $settled (SDK wants $expected)"
903
+ yarn add "$name@$settled" >/dev/null 2>&1 || die "could not install $name@$settled"
904
+ fi
905
+ done
906
+ done
907
+
908
+ # Whatever is still off must be something we deliberately deferred, or the
909
+ # scaffold is wrong and has to say so.
910
+ local unresolved=0 line name
911
+ while IFS= read -r line; do
912
+ name="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+(.+)@[^@]+ - expected version: .*/\1/')"
913
+ printf '%s\n' "${deferred[@]:-}" | grep -qx "$name" || { warn "off the SDK table: $line"; unresolved=1; }
914
+ done < <(yarn expo install --check 2>&1 | grep -E '^[[:space:]]+\S+@\S+ - expected version: ' || true)
915
+ [ "$unresolved" = 0 ] || die "packages remain off the installed Expo SDK"
916
+
917
+ # A deliberate, explained deviation has to be recorded where the tooling
918
+ # reads it, or `expo-doctor` fails on a brand-new project and the gate is
919
+ # broken before anyone has written a line of code. expo.install.exclude is
920
+ # the mechanism Expo provides for exactly this; orchestra.ageGateDeferred
921
+ # records which entries are ours, so the next run can clear them.
922
+ if [ ${#deferred[@]} -gt 0 ]; then
923
+ DEFERRED="${deferred[*]}" node -e '
924
+ const fs = require("fs");
925
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
926
+ const names = process.env.DEFERRED.split(/\s+/).filter(Boolean);
927
+ p.expo = p.expo || {};
928
+ p.expo.install = p.expo.install || {};
929
+ p.expo.install.exclude = [...new Set([...(p.expo.install.exclude || []), ...names])];
930
+ p.orchestra = { ...(p.orchestra || {}), ageGateDeferred: names };
931
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
932
+ '
933
+ warn "recorded in expo.install.exclude — re-run once these clear the age gate"
934
+ fi
935
+
936
+ # expo install rewrites package.json with its own formatting.
937
+ yarn biome check --write package.json >/dev/null 2>&1 || true
938
+ log "every SDK-managed package is on the newest version the SDK allows"
939
+ }
940
+
941
+ # A pinned action version ages exactly like a pinned package version, and
942
+ # actions/checkout@v4 in a workflow generated today is a dependabot PR that
943
+ # has already been written. Resolve the current major tag instead.
944
+ # Falls back to the given value when the API is unreachable or rate-limited.
945
+ latest_action_ref() { # owner/repo fallback
946
+ local repo="$1" fallback="$2" tag
947
+ tag="$(curl -fsSL --max-time 10 "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null \
948
+ | node -e '
949
+ let s = "";
950
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
951
+ try {
952
+ const t = JSON.parse(s).tag_name;
953
+ if (t) process.stdout.write(String(t).split(".")[0]);
954
+ } catch {
955
+ // Optional by design: the ref simply does not exist here.
956
+ }
957
+ });
958
+ ' 2>/dev/null)" || true
959
+ if [ -n "$tag" ]; then
960
+ printf '%s' "$tag"
961
+ else
962
+ warn "could not resolve the current ${repo} release — using ${fallback}"
963
+ printf '%s' "$fallback"
964
+ fi
965
+ }
966
+
967
+ # The first install of a freshly generated package.json is the one place the
968
+ # age gate cannot be worked around by resolving versions ourselves — the
969
+ # create tool already wrote the ranges, and one of them may name a version
970
+ # published an hour ago. create-expo-app does this routinely: it writes
971
+ # "expo": "~57.0.15" the day 57.0.15 ships, yarn quarantines it, and the
972
+ # whole scaffold dies on its first install.
973
+ #
974
+ # Turning the gate off is not the fix. Step the quarantined ranges back to
975
+ # the newest version that has settled, staying inside what the range meant.
976
+ yarn_install() {
977
+ # Yarn turns on immutable installs by itself when CI is set, and an
978
+ # immutable install refuses to create a lockfile — "YN0028: The lockfile
979
+ # would have been created by this install, which is explicitly forbidden".
980
+ # That is the right default for a repo that has one and a guaranteed failure
981
+ # for a scaffold that is producing it for the first time. Scoped to this
982
+ # function, so the generated project still gets the strict behaviour.
983
+ export YARN_ENABLE_IMMUTABLE_INSTALLS=false
984
+
985
+ # Captured on the first attempt, not by running the install a second time.
986
+ # The old shape ran it twice — once for the person watching, once to grep —
987
+ # and the second run does not print what the first did: yarn had already
988
+ # resolved, so the quarantine notice was gone and every age-gate failure
989
+ # was reported as "not the age gate". CI found this on expo.
990
+ local out rc=0 specs=()
991
+ out="$(yarn install 2>&1)" || rc=$?
992
+ printf '%s\n' "$out"
993
+ # An if, not `[ ... ] && return`: under set -e a failing test as the final
994
+ # command of a && list exits the script, which would abort the scaffold at
995
+ # exactly the point this function exists to recover from.
996
+ if [ "$rc" -eq 0 ]; then return 0; fi
997
+ # YN0016: │ expo@npm:~57.0.15: All versions satisfying "~57.0.15" are quarantined
998
+ while IFS= read -r spec; do
999
+ [ -n "$spec" ] && specs+=("$spec")
1000
+ # ANSI stripped first. Yarn colourises its output when GITHUB_ACTIONS is
1001
+ # set — not when it is merely CI — so in Actions the package name arrives
1002
+ # wrapped in escape codes and every pattern here missed it. That is why this
1003
+ # branch reported "not the age gate" on the one stack that hits the age gate.
1004
+ done < <(printf '%s\n' "$out" \
1005
+ | sed $'s/\033\[[0-9;]*m//g' \
1006
+ | grep -o '[^ │]*@npm:[^:]*: All versions satisfying' \
1007
+ | sed 's/: All versions satisfying$//')
1008
+
1009
+ [ ${#specs[@]} -gt 0 ] || die "yarn install failed for a reason that is not the age gate — see the output above"
1010
+
1011
+ warn "the age gate quarantined ${#specs[@]} package(s) the template asked for"
1012
+ local spec name range
1013
+ for spec in "${specs[@]}"; do
1014
+ name="${spec%@npm:*}"
1015
+ range="${spec##*@npm:}"
1016
+ local settled
1017
+ settled="$(settled_near "$name" "$range")" || { warn " no settled version for $name — leaving $range alone"; continue; }
1018
+ node -e '
1019
+ const fs = require("fs");
1020
+ const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
1021
+ const [name, range, next] = process.argv.slice(1);
1022
+ const prefix = (range.match(/^[\^~]/) || [""])[0];
1023
+ for (const field of ["dependencies", "devDependencies"]) {
1024
+ if (p[field] && p[field][name]) p[field][name] = prefix + next;
1025
+ }
1026
+ fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
1027
+ ' "$name" "$range" "$settled"
1028
+ log " ${name} ${range} -> ${settled} (newest past the gate)"
1029
+ done
1030
+
1031
+ yarn install || die "yarn install still fails after stepping back the quarantined packages"
1032
+ }
1033
+
1034
+ # Newest settled version of a package that stays inside what a range meant:
1035
+ # the same major.minor for a tilde range, the same major otherwise. Falling
1036
+ # outside that is a different package as far as the scaffold is concerned.
1037
+ settled_near() { # pkg range
1038
+ local pkg="$1" range="$2" gate
1039
+ gate="$(yarn config get npmMinimalAgeGate 2>/dev/null || echo 1440)"
1040
+ case "$gate" in ''|*[!0-9]*) gate=1440 ;; esac
1041
+ GATE_MINUTES="$gate" RANGE="$range" node -e '
1042
+ const name = process.argv[1];
1043
+ const range = process.env.RANGE || "";
1044
+ const cutoff = Date.now() - Number(process.env.GATE_MINUTES) * 60 * 1000;
1045
+ const bare = range.replace(/^[\^~>=\s]*/, "");
1046
+ const [major, minor] = bare.split(".");
1047
+ const tilde = range.trim().startsWith("~");
1048
+ const cmp = (a, b) => {
1049
+ const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
1050
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
1051
+ return 0;
1052
+ };
1053
+ fetch("https://registry.npmjs.org/" + name.replace("/", "%2f"))
1054
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("registry " + r.status))))
1055
+ .then((doc) => {
1056
+ const settled = Object.entries(doc.time || {})
1057
+ .filter(([v]) => /^\d+\.\d+\.\d+$/.test(v))
1058
+ .filter(([, t]) => Date.parse(t) < cutoff)
1059
+ .map(([v]) => v)
1060
+ .filter((v) => {
1061
+ const [vMajor, vMinor] = v.split(".");
1062
+ if (major && vMajor !== major) return false;
1063
+ if (tilde && minor && vMinor !== minor) return false;
1064
+ return true;
1065
+ })
1066
+ .sort(cmp);
1067
+ if (!settled.length) process.exit(1);
1068
+ process.stdout.write(settled[settled.length - 1]);
1069
+ })
1070
+ .catch(() => process.exit(1));
1071
+ ' "$pkg"
1072
+ }
1073
+
1074
+ # Gradle has no safe "@latest" — a dynamic version destroys build
1075
+ # reproducibility, which is why the version catalog pins explicitly. That is
1076
+ # an argument for writing the version down, not for writing it from memory:
1077
+ # resolve it from the repository here, then pin the result. Explicit and
1078
+ # current, rather than explicit and frozen at the time this script was typed.
1079
+ #
1080
+ # Usage: maven_latest <repo-base-url> <group> <artifact> <fallback>
1081
+ maven_latest() {
1082
+ local repo="$1" group="$2" artifact="$3" fallback="$4" path v
1083
+ path="$(printf '%s' "$group" | tr '.' '/')"
1084
+ v="$(curl -fsSL --max-time 20 "${repo}/${path}/${artifact}/maven-metadata.xml" 2>/dev/null \
1085
+ | grep -oE '<version>[^<]+</version>' \
1086
+ | sed 's/<[^>]*>//g' \
1087
+ | grep -viE 'alpha|beta|rc[0-9]|-rc|dev|eap|snapshot|-M[0-9]' \
1088
+ | tail -1)" || true
1089
+ if [ -n "$v" ]; then
1090
+ printf '%s' "$v"
1091
+ else
1092
+ warn "could not resolve ${group}:${artifact} — using ${fallback}"
1093
+ printf '%s' "$fallback"
1094
+ fi
1095
+ }
1096
+
1097
+ GOOGLE_MAVEN="https://dl.google.com/dl/android/maven2"
1098
+ MAVEN_CENTRAL="https://repo1.maven.org/maven2"
1099
+
1100
+ # The gradle wrapper pins the build tool itself. Same rule as everything
1101
+ # else: read the current version, then write it down.
1102
+ gradle_latest() { # fallback
1103
+ local fallback="$1" v
1104
+ v="$(curl -fsSL --max-time 20 https://services.gradle.org/versions/current 2>/dev/null \
1105
+ | node -e '
1106
+ let s = "";
1107
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
1108
+ try {
1109
+ const j = JSON.parse(s);
1110
+ if (j.version && !j.nightly && !j.snapshot) process.stdout.write(j.version);
1111
+ } catch {
1112
+ // Optional by design: the ref simply does not exist here.
1113
+ }
1114
+ });
1115
+ ' 2>/dev/null)" || true
1116
+ if [ -n "$v" ]; then printf '%s' "$v"; else
1117
+ warn "could not resolve the current gradle release — using ${fallback}"
1118
+ printf '%s' "$fallback"
1119
+ fi
1120
+ }
1121
+
1122
+ # The gate that actually changes behaviour: branch coverage of the lines this
1123
+ # change touched. A global percentage reports the historical average and says
1124
+ # nothing about the code being written now.
1125
+ #
1126
+ # Written once at the repo root and shared by every app, like verify.sh.
1127
+ write_diff_coverage_script() {
1128
+ local root; root="$(git rev-parse --show-toplevel)"
1129
+ [ -f "$root/scripts/diff-coverage.mjs" ] && return 0
1130
+ mkdir -p "$root/scripts"
1131
+ cat > "$root/scripts/diff-coverage.mjs" <<'JS'
1132
+ #!/usr/bin/env node
1133
+ /**
1134
+ * Branch coverage of the lines this change touched.
1135
+ *
1136
+ * A global coverage percentage reports a historical average. It cannot tell
1137
+ * you whether the code in this pull request was tested, which is the only
1138
+ * question a reviewer actually has.
1139
+ *
1140
+ * Usage: node scripts/diff-coverage.mjs [--app <dir>] [--base <ref>]
1141
+ * Reads the threshold from .orchestra/config.yml (testing.coverage
1142
+ * .diff_branch_threshold), falling back to 90.
1143
+ */
1144
+ import { execFileSync } from "node:child_process";
1145
+ import { existsSync, readFileSync } from "node:fs";
1146
+ import { join, relative, resolve } from "node:path";
1147
+
1148
+ const argv = process.argv.slice(2);
1149
+ const arg = (name, fallback) => {
1150
+ const i = argv.indexOf(`--${name}`);
1151
+ return i === -1 ? fallback : argv[i + 1];
1152
+ };
1153
+
1154
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1155
+ const app = resolve(arg("app", process.cwd()));
1156
+
1157
+ function threshold() {
1158
+ const f = join(root, ".orchestra", "config.yml");
1159
+ if (!existsSync(f)) return 90;
1160
+ const m = readFileSync(f, "utf8").match(/^\s*diff_branch_threshold:\s*(\d+)/m);
1161
+ return m ? Number(m[1]) : 90;
1162
+ }
1163
+
1164
+ /** The first ref that resolves. A shallow CI clone may have none of them. */
1165
+ function baseRef() {
1166
+ const explicit = arg("base", process.env.DIFF_COVERAGE_BASE);
1167
+ const candidates = explicit ? [explicit] : ["origin/main", "origin/master", "main", "master"];
1168
+ for (const ref of candidates) {
1169
+ try {
1170
+ execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { stdio: "pipe" });
1171
+ return ref;
1172
+ } catch {
1173
+ // Optional by design: the ref simply does not exist here.
1174
+ }
1175
+ }
1176
+ return null;
1177
+ }
1178
+
1179
+ /** Added line numbers per file, from a zero-context diff. */
1180
+ function changedLines(base) {
1181
+ const out = execFileSync(
1182
+ "git",
1183
+ ["diff", "--unified=0", "--no-color", `${base}...HEAD`, "--", relative(root, app) || "."],
1184
+ { encoding: "utf8", cwd: root, maxBuffer: 64 * 1024 * 1024 }
1185
+ );
1186
+ const files = new Map();
1187
+ let current = null;
1188
+ for (const line of out.split("\n")) {
1189
+ if (line.startsWith("+++ b/")) {
1190
+ current = line.slice(6);
1191
+ if (!files.has(current)) files.set(current, new Set());
1192
+ continue;
1193
+ }
1194
+ if (!current || !line.startsWith("@@")) continue;
1195
+ // @@ -12,0 +13,4 @@ → lines 13..16 are new
1196
+ const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
1197
+ if (!m) continue;
1198
+ const start = Number(m[1]);
1199
+ const count = m[2] === undefined ? 1 : Number(m[2]);
1200
+ for (let i = 0; i < count; i++) files.get(current).add(start + i);
1201
+ }
1202
+ return files;
1203
+ }
1204
+
1205
+ /** Only source files. A config or a test moving is not a coverage question. */
1206
+ const isSource = (p) =>
1207
+ /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p) &&
1208
+ !/\.(test|spec)\./.test(p) &&
1209
+ !/\.config\./.test(p) &&
1210
+ !/\.d\.ts$/.test(p) &&
1211
+ !/(^|\/)(node_modules|dist|build|coverage|\.next)\//.test(p);
1212
+
1213
+ const base = baseRef();
1214
+ if (!base) {
1215
+ console.log(" diff coverage: no base ref resolved — skipped");
1216
+ process.exit(0);
1217
+ }
1218
+
1219
+ const reportPath = join(app, "coverage", "coverage-final.json");
1220
+ if (!existsSync(reportPath)) {
1221
+ console.error(` diff coverage: no coverage report at ${reportPath} — run the coverage script first`);
1222
+ process.exit(1);
1223
+ }
1224
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
1225
+ // Report keys are absolute; the diff is repo-relative.
1226
+ const byRelative = new Map(
1227
+ Object.entries(report).map(([abs, data]) => [relative(root, abs), data])
1228
+ );
1229
+
1230
+ const files = [...changedLines(base)].filter(([f]) => isSource(f));
1231
+ if (!files.length) {
1232
+ console.log(" diff coverage: no source lines changed — nothing to gate");
1233
+ process.exit(0);
1234
+ }
1235
+
1236
+ let total = 0;
1237
+ let covered = 0;
1238
+ const misses = [];
1239
+
1240
+ for (const [file, lines] of files) {
1241
+ const data = byRelative.get(file);
1242
+ if (!data) {
1243
+ // Changed source the coverage run never loaded: nothing imported it, so
1244
+ // nothing tested it. That is a miss, not an absence of data.
1245
+ misses.push(`${file} — not present in the coverage report (no test imports it)`);
1246
+ total += 1;
1247
+ continue;
1248
+ }
1249
+ let fileTotal = 0;
1250
+ let fileCovered = 0;
1251
+
1252
+ for (const [id, meta] of Object.entries(data.branchMap || {})) {
1253
+ const counts = data.b?.[id] || [];
1254
+ (meta.locations || []).forEach((loc, k) => {
1255
+ const line = loc?.start?.line;
1256
+ if (!line || !lines.has(line)) return;
1257
+ fileTotal++;
1258
+ if ((counts[k] || 0) > 0) fileCovered++;
1259
+ });
1260
+ }
1261
+
1262
+ // A changed line with no branch on it still has to have run at least once,
1263
+ // otherwise a whole new untested function reads as 100%.
1264
+ for (const [id, meta] of Object.entries(data.statementMap || {})) {
1265
+ const line = meta?.start?.line;
1266
+ if (!line || !lines.has(line)) continue;
1267
+ fileTotal++;
1268
+ if ((data.s?.[id] || 0) > 0) fileCovered++;
1269
+ }
1270
+
1271
+ if (!fileTotal) continue;
1272
+ total += fileTotal;
1273
+ covered += fileCovered;
1274
+ if (fileCovered < fileTotal) {
1275
+ misses.push(`${file} — ${fileCovered}/${fileTotal} on changed lines`);
1276
+ }
1277
+ }
1278
+
1279
+ if (!total) {
1280
+ console.log(" diff coverage: changed lines carry nothing executable — nothing to gate");
1281
+ process.exit(0);
1282
+ }
1283
+
1284
+ const pct = (covered / total) * 100;
1285
+ const min = threshold();
1286
+ console.log(` diff coverage: ${pct.toFixed(1)}% of changed lines (${covered}/${total}), gate ${min}% [base ${base}]`);
1287
+ for (const m of misses) console.log(` - ${m}`);
1288
+
1289
+ if (pct + 1e-9 < min) {
1290
+ console.error(` [x] changed lines are below the ${min}% gate`);
1291
+ process.exit(1);
1292
+ }
1293
+ JS
1294
+ chmod +x "$root/scripts/diff-coverage.mjs"
1295
+ log "wrote scripts/diff-coverage.mjs"
1296
+ }
1297
+
1298
+ # Coverage says a line ran. Mutation says the line was checked.
1299
+ #
1300
+ # Stryker rewrites the code one small change at a time — `>` becomes `>=`, a
1301
+ # return value is replaced — and runs the suite. A test suite that still
1302
+ # passes was executing that code without asserting anything about it.
1303
+ #
1304
+ # A full run takes hours, so the gate is scoped to the files a pull request
1305
+ # changed. The full run is a scheduled job.
1306
+ setup_mutation() {
1307
+ # TypeScript 7 is the Go port, and it dropped the compiler JS API entirely:
1308
+ # createProgram, readConfigFile and parseConfigFileTextToJson no longer
1309
+ # exist. Stryker's sandbox preprocessor calls into that API before any of
1310
+ # its own options are consulted, so it crashes on every run regardless of
1311
+ # `checkers` or `tsconfigFile`. Installing it anyway would ship a package
1312
+ # script that cannot work.
1313
+ #
1314
+ # This is a check on Stryker's dependency, not a permanent verdict. Drop it
1315
+ # when Stryker supports the new compiler.
1316
+ local ts_major
1317
+ ts_major="$(node -e 'try{process.stdout.write(String(require("typescript").version).split(".")[0])}catch{process.stdout.write("0")}' 2>/dev/null || echo 0)"
1318
+ if [ "${ts_major:-0}" -ge 7 ]; then
1319
+ warn "mutation testing skipped: stryker needs the typescript compiler API,"
1320
+ warn "which typescript ${ts_major} removed. Re-run setup_mutation once stryker"
1321
+ warn "supports it — everything else it needs is already in place."
1322
+ return 0
1323
+ fi
1324
+
1325
+ local runner
1326
+ if node -e 'const p=require("./package.json");const d={...p.dependencies,...p.devDependencies};process.exit(d.vitest?0:1)' 2>/dev/null; then
1327
+ runner="vitest"
1328
+ elif node -e 'const p=require("./package.json");const d={...p.dependencies,...p.devDependencies};process.exit(d.jest?0:1)' 2>/dev/null; then
1329
+ runner="jest"
1330
+ else
1331
+ warn "no vitest or jest found — mutation testing not configured"
1332
+ return 0
1333
+ fi
1334
+
1335
+ add_latest --dev "@stryker-mutator/core" "@stryker-mutator/${runner}-runner" "@stryker-mutator/typescript-checker"
1336
+
1337
+ if [ ! -f stryker.config.json ]; then
1338
+ RUNNER="$runner" node -e '
1339
+ const fs = require("fs");
1340
+ fs.writeFileSync("stryker.config.json", JSON.stringify({
1341
+ $schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
1342
+ testRunner: process.env.RUNNER,
1343
+ checkers: ["typescript"],
1344
+ tsconfigFile: "tsconfig.json",
1345
+ // Only run the tests that actually cover each mutant.
1346
+ coverageAnalysis: "perTest",
1347
+ mutate: ["src/**/*.ts", "src/**/*.tsx", "!src/**/*.test.*", "!src/**/*.d.ts"],
1348
+ // Caches results between runs so a scoped run stays fast.
1349
+ incremental: true,
1350
+ reporters: ["clear-text", "progress"],
1351
+ // break is the failing gate; see testing.mutation.break_threshold in
1352
+ // .orchestra/config.yml. scripts/mutation-diff.mjs passes the current
1353
+ // value, so this is only the floor for a direct `yarn test:mutation`.
1354
+ thresholds: { high: 85, low: 70, break: 70 },
1355
+ }, null, 2) + "\n");
1356
+ '
1357
+ fi
1358
+
1359
+ write_mutation_diff_script
1360
+ # The app is not always two levels down — a single-app repo has no apps/.
1361
+ # Resolve the path rather than assuming the layout.
1362
+ local root rel
1363
+ root="$(git rev-parse --show-toplevel)"
1364
+ rel="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$root/scripts/mutation-diff.mjs")"
1365
+ pkg_set scripts.test:mutation="stryker run" \
1366
+ scripts.test:mutation:diff="node $rel"
1367
+
1368
+ # Stryker writes its incremental cache and reports here. Neither belongs in
1369
+ # a commit — CI restores the cache from its own store.
1370
+ if ! grep -q '^reports/' .gitignore 2>/dev/null; then
1371
+ printf '\n# stryker\nreports/\n.stryker-tmp/\n' >> .gitignore
1372
+ fi
1373
+
1374
+ log "mutation testing (${runner} runner), scoped to changed files"
1375
+ }
1376
+
1377
+ # Companion to the above: works out what this change touched and hands only
1378
+ # those files to Stryker.
1379
+ write_mutation_diff_script() {
1380
+ local root; root="$(git rev-parse --show-toplevel)"
1381
+ [ -f "$root/scripts/mutation-diff.mjs" ] && return 0
1382
+ mkdir -p "$root/scripts"
1383
+ cat > "$root/scripts/mutation-diff.mjs" <<'JS'
1384
+ #!/usr/bin/env node
1385
+ /**
1386
+ * Mutation testing over the files this change touched.
1387
+ *
1388
+ * A full Stryker run takes hours, which is why it belongs on a schedule. The
1389
+ * per-pull-request gate mutates only what changed, which is where a missing
1390
+ * assertion was just introduced anyway.
1391
+ *
1392
+ * Usage: node scripts/mutation-diff.mjs [--base <ref>] (run from the app dir)
1393
+ */
1394
+ import { execFileSync, spawnSync } from "node:child_process";
1395
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1396
+ import { join, relative, resolve } from "node:path";
1397
+
1398
+ const argv = process.argv.slice(2);
1399
+ const arg = (name, fallback) => {
1400
+ const i = argv.indexOf(`--${name}`);
1401
+ return i === -1 ? fallback : argv[i + 1];
1402
+ };
1403
+
1404
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1405
+ const app = resolve(process.cwd());
1406
+
1407
+ function breakThreshold() {
1408
+ const f = join(root, ".orchestra", "config.yml");
1409
+ if (!existsSync(f)) return 70;
1410
+ const m = readFileSync(f, "utf8").match(/^\s*break_threshold:\s*(\d+)/m);
1411
+ return m ? Number(m[1]) : 70;
1412
+ }
1413
+
1414
+ function baseRef() {
1415
+ const explicit = arg("base", process.env.MUTATION_BASE);
1416
+ const candidates = explicit ? [explicit] : ["origin/main", "origin/master", "main", "master"];
1417
+ for (const ref of candidates) {
1418
+ try {
1419
+ execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { stdio: "pipe" });
1420
+ return ref;
1421
+ } catch {
1422
+ // Optional by design: the ref simply does not exist here.
1423
+ }
1424
+ }
1425
+ return null;
1426
+ }
1427
+
1428
+ const base = baseRef();
1429
+ if (!base) {
1430
+ console.log(" mutation: no base ref resolved — skipped");
1431
+ process.exit(0);
1432
+ }
1433
+
1434
+ const appPrefix = relative(root, app);
1435
+ const changed = execFileSync(
1436
+ "git",
1437
+ ["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`, "--", appPrefix || "."],
1438
+ { encoding: "utf8", cwd: root }
1439
+ )
1440
+ .split("\n")
1441
+ .filter(Boolean)
1442
+ // Repo-relative from git, app-relative for stryker.
1443
+ .map((f) => (appPrefix ? relative(appPrefix, f) : f))
1444
+ .filter(
1445
+ (f) =>
1446
+ /^src\/.*\.(ts|tsx)$/.test(f) &&
1447
+ !/\.(test|spec)\./.test(f) &&
1448
+ !/\.d\.ts$/.test(f)
1449
+ );
1450
+
1451
+ if (!changed.length) {
1452
+ console.log(" mutation: no source files changed — nothing to mutate");
1453
+ process.exit(0);
1454
+ }
1455
+
1456
+ console.log(` mutation: ${changed.length} changed file(s) [base ${base}]`);
1457
+ for (const f of changed) console.log(` - ${f}`);
1458
+
1459
+ // Stryker has no CLI flag for thresholds, and rewriting the committed
1460
+ // stryker.config.json at run time would leave a dirty tree. Generate a
1461
+ // throwaway config instead, so .orchestra/config.yml stays the one place the
1462
+ // break threshold is written down.
1463
+ const strykerConfig = existsSync(join(app, "stryker.config.json"))
1464
+ ? JSON.parse(readFileSync(join(app, "stryker.config.json"), "utf8"))
1465
+ : {};
1466
+ strykerConfig.thresholds = { ...(strykerConfig.thresholds || {}), break: breakThreshold() };
1467
+ const tmpDir = join(app, "reports");
1468
+ mkdirSync(tmpDir, { recursive: true });
1469
+ const tmpConfig = join(tmpDir, "stryker-diff.config.json");
1470
+ writeFileSync(tmpConfig, `${JSON.stringify(strykerConfig, null, 2)}\n`);
1471
+
1472
+ const res = spawnSync(
1473
+ "npx",
1474
+ [
1475
+ "stryker",
1476
+ "run",
1477
+ // The config file is a positional argument, not a flag.
1478
+ relative(app, tmpConfig),
1479
+ "--mutate",
1480
+ changed.join(","),
1481
+ "--incremental",
1482
+ // A change that produces no mutants is not a failure.
1483
+ "--allowEmpty",
1484
+ ],
1485
+ { stdio: "inherit", cwd: app }
1486
+ );
1487
+ process.exit(res.status ?? 1);
1488
+ JS
1489
+ chmod +x "$root/scripts/mutation-diff.mjs"
1490
+ log "wrote scripts/mutation-diff.mjs"
1491
+ }
1492
+
1493
+ # Jest's equivalent of the vitest block above. Jest has no autoUpdate, so the
1494
+ # ratchet is a script that raises the committed threshold after a run that
1495
+ # beat it; jest's own coverageThreshold is what fails on a drop.
1496
+ # The folder the seam threshold points at, with the reason it exists written
1497
+ # where an agent will actually meet it — in the repo, not only in a skill.
1498
+ setup_integrations_dir() {
1499
+ mkdir -p src/integrations
1500
+ cat > src/integrations/README.md <<'MD'
1501
+ # integrations
1502
+
1503
+ Anything that calls a third party's SDK or HTTP API lives here, one directory
1504
+ per provider. Nothing else imports that SDK.
1505
+
1506
+ ## Why
1507
+
1508
+ **So the test suite has something to hold.** The rule in
1509
+ `.claude/rules/testing-standards.orchestra.md` is that a function calling into
1510
+ a third-party library has a test, and that a dependency bump which changes
1511
+ behaviour turns the suite red. Called inline across a dozen handlers there is
1512
+ no function to test, and the bump goes green.
1513
+
1514
+ Coverage here is held at **100%** for that reason — these modules are small,
1515
+ so it is achievable, unlike a global 100%.
1516
+
1517
+ **And so the policy is applied once.** Timeout, bounded retry, circuit
1518
+ breaker, error mapping, keeping the key out of the logs — see
1519
+ `.claude/skills/architecture/SKILL.md`. Spread across a dozen call sites you
1520
+ will miss one, and the one you miss is the one that hangs.
1521
+
1522
+ ## What this is not
1523
+
1524
+ It is not an abstraction that makes the provider swappable. That is a guess
1525
+ about a migration that usually never happens, and it produces a
1526
+ lowest-common-denominator wrapper worse than the SDK. A wrapper here is thin,
1527
+ exposes what this app needs in this app's types, and is allowed to look
1528
+ obviously like the provider it wraps.
1529
+
1530
+ ## The threshold asks for exercise, not a test file
1531
+
1532
+ An integration test that drives the wrapper through a faked HTTP boundary
1533
+ satisfies it. Do not add a unit test beside the wrapper to repeat what a route
1534
+ test already covers — see the same rule on not writing tests to move a number.
1535
+ MD
1536
+ log "src/integrations/ — third-party wrappers, held at 100%"
1537
+ }
1538
+
1539
+ setup_coverage_jest() {
1540
+ # The reporters below write coverage/ into the app. create-next-app ignores
1541
+ # it; create-hono and create-expo-app do not, so without this the first
1542
+ # `git add -A` commits an HTML coverage report.
1543
+ if ! grep -qE '^/?coverage' .gitignore 2>/dev/null; then
1544
+ printf '\n# coverage output\ncoverage/\n' >> .gitignore
1545
+ fi
1546
+
1547
+ pkg_set jest.coverageThreshold.global.lines=0 \
1548
+ jest.coverageThreshold.global.functions=0 \
1549
+ jest.coverageThreshold.global.branches=0 \
1550
+ jest.coverageThreshold.global.statements=0
1551
+ # json-summary feeds the ratchet, json feeds scripts/diff-coverage.mjs.
1552
+ pkg_set 'jest.coverageReporters=["text","json","json-summary","lcov"]'
1553
+ pkg_set 'jest.coveragePathIgnorePatterns=["/node_modules/","\\.d\\.ts$","/design/tokens\\.","\\.config\\.","/index\\.ts$"]'
1554
+ # Every source file, not only the ones a test happened to load — same
1555
+ # reason as the vitest branch. coveragePathIgnorePatterns above is where
1556
+ # something that genuinely should not count goes.
1557
+ pkg_set 'jest.collectCoverageFrom=["src/**/*.{ts,tsx}"]'
1558
+
1559
+ write_diff_coverage_script
1560
+ write_coverage_ratchet_script
1561
+
1562
+ local root rel_ratchet rel_diff
1563
+ root="$(git rev-parse --show-toplevel)"
1564
+ rel_ratchet="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$root/scripts/coverage-ratchet.mjs")"
1565
+ rel_diff="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$root/scripts/diff-coverage.mjs")"
1566
+ # Same reason as the vitest branch: the ratchet rewrites package.json, and
1567
+ # an unformatted package.json fails the next lint run.
1568
+ # The seam sync runs first: jest fails outright when the glob matches
1569
+ # nothing, so the key has to be right before jest starts, not after.
1570
+ pkg_set scripts.test:coverage="node ${rel_ratchet} --sync-seams && jest --coverage --passWithNoTests && node ${rel_ratchet} && biome check --write package.json"
1571
+ pkg_set scripts.test:coverage:diff="node ${rel_diff}"
1572
+ log "coverage: ratchet + diff gate (jest)"
1573
+ }
1574
+
1575
+ # Clamps the floor vitest's autoUpdate just wrote.
1576
+ #
1577
+ # autoUpdate records whatever the run achieved, which on a fresh scaffold is
1578
+ # 100% — the app is 71 lines and fully tested. That turns the ratchet into a
1579
+ # 100% line-coverage mandate on day one, stricter than the diff gate, decided
1580
+ # by nobody. The clamp holds the floor at testing.coverage.ratchet_ceiling.
1581
+ #
1582
+ # Only the global keys are touched, and the cap decides that by nesting depth
1583
+ # rather than by how the file happens to be formatted. It used to rely on a
1584
+ # seam glob sitting on one line so a line-anchored match could not reach it —
1585
+ # then a formatter broke the line apart and the seam gate was silently
1586
+ # rewritten from 100 to 90 on every web app.
1587
+ write_coverage_cap_script() {
1588
+ local root; root="$(git rev-parse --show-toplevel)"
1589
+ [ -f "$root/scripts/coverage-cap.mjs" ] && return 0
1590
+ mkdir -p "$root/scripts"
1591
+ cat > "$root/scripts/coverage-cap.mjs" <<'JS'
1592
+ #!/usr/bin/env node
1593
+ /**
1594
+ * Hold the coverage ratchet below a ceiling.
1595
+ *
1596
+ * Vitest's thresholds.autoUpdate writes down whatever the run achieved. On a
1597
+ * small, fully covered codebase that is 100, and the floor then demands every
1598
+ * future line be covered — a target nobody set, and stricter than the diff
1599
+ * gate that was chosen on purpose. Tests written to satisfy it are tests
1600
+ * written to move a number, which .claude/rules/testing-standards.orchestra.md
1601
+ * says to refuse rather than comply with.
1602
+ *
1603
+ * Run after `vitest run --coverage`, from the app directory.
1604
+ */
1605
+ import { execFileSync } from "node:child_process";
1606
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1607
+ import { join } from "node:path";
1608
+
1609
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1610
+
1611
+ function ceiling() {
1612
+ const f = join(root, ".orchestra", "config.yml");
1613
+ if (!existsSync(f)) return 90;
1614
+ const m = readFileSync(f, "utf8").match(/^\s*ratchet_ceiling:\s*(\d+(?:\.\d+)?)/m);
1615
+ return m ? Number(m[1]) : 90;
1616
+ }
1617
+
1618
+ const configPath = join(process.cwd(), "vitest.config.ts");
1619
+ if (!existsSync(configPath)) {
1620
+ console.log(" coverage cap: no vitest.config.ts — skipped");
1621
+ process.exit(0);
1622
+ }
1623
+
1624
+ const cap = ceiling();
1625
+ const before = readFileSync(configPath, "utf8");
1626
+
1627
+ /**
1628
+ * Only the top-level ratchet, never a per-glob threshold.
1629
+ *
1630
+ * This used to rely on the seam globs being written on one line, so a
1631
+ * line-anchored regex could not reach them. A formatter breaks that line
1632
+ * across several, and then the seam gate the whole testing rule rests on was
1633
+ * quietly rewritten from 100 to 90 — on every web app, invisibly.
1634
+ *
1635
+ * Depth is the thing that actually distinguishes them: the ratchet's keys are
1636
+ * direct children of `thresholds`, a per-glob block is nested one deeper. So
1637
+ * count braces instead of trusting how the file happens to be formatted.
1638
+ */
1639
+ const capped = [];
1640
+ const lines = before.split("\n");
1641
+ let depth = -1; // -1 until `thresholds: {` is seen; 0 inside it
1642
+ for (let i = 0; i < lines.length; i++) {
1643
+ const line = lines[i];
1644
+ if (depth === -1) {
1645
+ if (/\bthresholds\s*:\s*\{/.test(line)) depth = 0;
1646
+ continue;
1647
+ }
1648
+
1649
+ const match = /^(\s*)(lines|functions|branches|statements): (\d+(?:\.\d+)?),$/.exec(line);
1650
+ if (match && depth === 0 && Number(match[3]) > cap) {
1651
+ capped.push(`${match[2]} ${match[3]} -> ${cap}`);
1652
+ lines[i] = `${match[1]}${match[2]}: ${cap},`;
1653
+ }
1654
+
1655
+ const opens = (line.match(/\{/g) || []).length;
1656
+ const closes = (line.match(/\}/g) || []).length;
1657
+ depth += opens - closes;
1658
+ if (depth < 0) break; // out of the thresholds block
1659
+ }
1660
+ const after = lines.join("\n");
1661
+
1662
+ if (!capped.length) {
1663
+ console.log(` coverage cap: floor already at or below ${cap}`);
1664
+ process.exit(0);
1665
+ }
1666
+
1667
+ writeFileSync(configPath, after);
1668
+ console.log(` coverage cap: ${capped.join(", ")}`);
1669
+ JS
1670
+ chmod +x "$root/scripts/coverage-cap.mjs"
1671
+ log "wrote scripts/coverage-cap.mjs"
1672
+ }
1673
+
1674
+ # Raises the committed jest threshold to what the run actually achieved.
1675
+ # Never lowers it — that is the whole point.
1676
+ write_coverage_ratchet_script() {
1677
+ local root; root="$(git rev-parse --show-toplevel)"
1678
+ [ -f "$root/scripts/coverage-ratchet.mjs" ] && return 0
1679
+ mkdir -p "$root/scripts"
1680
+ cat > "$root/scripts/coverage-ratchet.mjs" <<'JS'
1681
+ #!/usr/bin/env node
1682
+ /**
1683
+ * Coverage ratchet for jest. Vitest does this itself via
1684
+ * thresholds.autoUpdate; jest does not.
1685
+ *
1686
+ * Two jobs, and the order matters:
1687
+ *
1688
+ * --sync-seams runs BEFORE jest. Adds the seam threshold when the first
1689
+ * third-party wrapper appears under src/integrations/, and
1690
+ * removes it when the last one goes. Jest fails the whole run
1691
+ * with "Coverage data for <glob> was not found" when the glob
1692
+ * matches nothing, so a fresh app cannot carry the key — and
1693
+ * an app whose last wrapper was just deleted would be stuck
1694
+ * failing if the key were only removed afterwards.
1695
+ *
1696
+ * (no flag) runs AFTER jest. Raises the committed global threshold to
1697
+ * what the run achieved, and never lowers it. Jest's own
1698
+ * check is what fails a drop; this only moves the floor up.
1699
+ *
1700
+ * The seam threshold asks that the wrapper be exercised, not that a test file
1701
+ * sit beside it: an integration test driving it through a faked HTTP boundary
1702
+ * satisfies it.
1703
+ *
1704
+ * Run from the app directory.
1705
+ */
1706
+ import { execFileSync } from "node:child_process";
1707
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1708
+ import { join } from "node:path";
1709
+
1710
+ const syncSeamsOnly = process.argv.includes("--sync-seams");
1711
+
1712
+ /**
1713
+ * The highest floor this is allowed to record. Without it the ratchet writes
1714
+ * down whatever a small, fully covered app achieved — 100 — and every line
1715
+ * written afterwards has to be covered or the build fails. That is stricter
1716
+ * than the diff gate, and it is a target nobody set.
1717
+ */
1718
+ function ceiling() {
1719
+ try {
1720
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1721
+ const f = join(root, ".orchestra", "config.yml");
1722
+ if (!existsSync(f)) return 90;
1723
+ const m = readFileSync(f, "utf8").match(/^\s*ratchet_ceiling:\s*(\d+(?:\.\d+)?)/m);
1724
+ return m ? Number(m[1]) : 90;
1725
+ } catch {
1726
+ // Not a git checkout, or no config: the default is the documented one.
1727
+ return 90;
1728
+ }
1729
+ }
1730
+ const pkgPath = join(process.cwd(), "package.json");
1731
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1732
+ pkg.jest = pkg.jest || {};
1733
+ pkg.jest.coverageThreshold = pkg.jest.coverageThreshold || {};
1734
+
1735
+ const notes = [];
1736
+
1737
+ /** Seam key maintenance — see the header. */
1738
+ const SEAM_GLOB = "./src/integrations/**";
1739
+ const seamDir = join(process.cwd(), "src", "integrations");
1740
+ const hasSeams =
1741
+ existsSync(seamDir) &&
1742
+ readdirSync(seamDir, { recursive: true })
1743
+ .map(String)
1744
+ .some((f) => /\.(ts|tsx|js|jsx)$/.test(f) && !/\.(test|spec)\./.test(f));
1745
+
1746
+ if (hasSeams && !pkg.jest.coverageThreshold[SEAM_GLOB]) {
1747
+ pkg.jest.coverageThreshold[SEAM_GLOB] = {
1748
+ lines: 100,
1749
+ functions: 100,
1750
+ branches: 100,
1751
+ statements: 100,
1752
+ };
1753
+ notes.push(`seam threshold on ${SEAM_GLOB}`);
1754
+ } else if (!hasSeams && pkg.jest.coverageThreshold[SEAM_GLOB]) {
1755
+ delete pkg.jest.coverageThreshold[SEAM_GLOB];
1756
+ notes.push("seam threshold removed (no wrappers left)");
1757
+ }
1758
+
1759
+ const save = () => {
1760
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
1761
+ console.log(` coverage ratchet: ${notes.join(", ")}`);
1762
+ };
1763
+
1764
+ if (syncSeamsOnly) {
1765
+ if (notes.length) save();
1766
+ process.exit(0);
1767
+ }
1768
+
1769
+ const summaryPath = join(process.cwd(), "coverage", "coverage-summary.json");
1770
+ if (!existsSync(summaryPath)) {
1771
+ if (notes.length) save();
1772
+ else console.log(" coverage ratchet: no summary report — skipped");
1773
+ process.exit(0);
1774
+ }
1775
+
1776
+ const total = JSON.parse(readFileSync(summaryPath, "utf8")).total;
1777
+ if (!total) {
1778
+ if (notes.length) save();
1779
+ else console.log(" coverage ratchet: summary has no totals — skipped");
1780
+ process.exit(0);
1781
+ }
1782
+
1783
+ const cap = ceiling();
1784
+ const current = pkg.jest.coverageThreshold.global || {};
1785
+ for (const key of ["lines", "functions", "branches", "statements"]) {
1786
+ // The seam globs are separate keys and are not capped — 100 there is the
1787
+ // point, and those modules are small enough for it to mean something.
1788
+ const achieved = Math.min(Math.floor(total[key]?.pct ?? 0), cap);
1789
+ const committed = current[key] ?? 0;
1790
+ if (achieved > committed) {
1791
+ current[key] = achieved;
1792
+ notes.push(`${key} ${committed} -> ${achieved}`);
1793
+ }
1794
+ }
1795
+
1796
+ if (!notes.length) {
1797
+ console.log(" coverage ratchet: no improvement to record");
1798
+ process.exit(0);
1799
+ }
1800
+
1801
+ pkg.jest.coverageThreshold.global = current;
1802
+ save();
1803
+ JS
1804
+ chmod +x "$root/scripts/coverage-ratchet.mjs"
1805
+ log "wrote scripts/coverage-ratchet.mjs"
1806
+ }
1807
+
1808
+ # End-to-end on a real simulator or emulator. This is the layer that is not
1809
+ # optional on mobile: jest-expo runs components in Node against mocked native
1810
+ # modules, so the entire JS suite can be green while the app does not launch.
1811
+ #
1812
+ # Maestro rather than Detox — YAML flows, no native build knowledge needed.
1813
+ # It is a CLI installed by its own installer, not an npm package, so this
1814
+ # writes the flows and the script and reports if the binary is absent.
1815
+ setup_maestro() {
1816
+ mkdir -p .maestro
1817
+
1818
+ # Maestro addresses the installed app by its id, so a guess here produces a
1819
+ # flow that runs against an app that is not on the device. Read it, and
1820
+ # refuse to write flows if it is not set — `expo prebuild` would otherwise
1821
+ # derive one from the machine's username, differently on every machine.
1822
+ local app_id
1823
+ app_id="$(node -e '
1824
+ const fs = require("fs");
1825
+ const f = ["app.json", "app.config.json"].find((x) => fs.existsSync(x));
1826
+ const cfg = f ? JSON.parse(fs.readFileSync(f, "utf8")).expo || {} : {};
1827
+ process.stdout.write(cfg.android?.package || cfg.ios?.bundleIdentifier || "");
1828
+ ')"
1829
+ [ -n "$app_id" ] || die "no android.package or ios.bundleIdentifier in app.json — maestro cannot address the app"
1830
+
1831
+ # The flow asserts a marker the app itself renders, rather than
1832
+ # `assertVisible: id: ".*"`. The loose form matches any element in the
1833
+ # foreground app's tree — including React Native's red "could not connect
1834
+ # to development server" screen — so it cannot tell a mounted app from one
1835
+ # that failed to load its bundle. app-root is rendered only by our own root
1836
+ # view, so matching it proves the tree mounted.
1837
+ #
1838
+ # Verified on a simulator: react-native maps testID to the iOS
1839
+ # accessibility identifier, which maestro matches as resource-id.
1840
+ #
1841
+ # Refuse rather than write the weaker assertion silently.
1842
+ grep -q 'testID="app-root"' App.tsx 2>/dev/null \
1843
+ || die "App.tsx has no testID=\"app-root\" — the launch flow could only assert that some element exists"
1844
+
1845
+ cat > .maestro/launch.yaml <<YAML
1846
+ # The single highest-value mobile check: the app starts, and it is the app in
1847
+ # front.
1848
+ #
1849
+ # Nothing in the JS suite can tell you this. jest-expo renders components in
1850
+ # Node against mocked native modules, so a missing native dependency, a bad
1851
+ # plugin or an SDK mismatch all pass every test and then fail here.
1852
+ #
1853
+ # app-root lives on the root view in App.tsx. Asserting anything looser —
1854
+ # "some element is visible" — also matches the red bundle-error screen, which
1855
+ # is exactly the failure this check exists to catch.
1856
+ appId: ${app_id}
1857
+ ---
1858
+ - launchApp:
1859
+ clearState: true
1860
+ - assertVisible:
1861
+ id: "app-root"
1862
+ YAML
1863
+
1864
+ cat > .maestro/core-flow.yaml <<YAML
1865
+ # The one flow that proves the app does its job, end to end, on a device.
1866
+ #
1867
+ # Fill this in for this app — it is deliberately not generated, because a
1868
+ # flow that asserts nothing passes forever and teaches you to trust it.
1869
+ #
1870
+ # The shape that earns its keep:
1871
+ # 1. sign in
1872
+ # 2. perform the app's core action once
1873
+ # 3. assert the screen reflects it
1874
+ # 4. kill and relaunch, assert it is still there
1875
+ #
1876
+ # Step 4 is the point. It catches storage and migration bugs that no other
1877
+ # layer sees.
1878
+ appId: ${app_id}
1879
+ ---
1880
+ - launchApp:
1881
+ clearState: true
1882
+ # - tapOn: "Sign in"
1883
+ # - inputText: "..."
1884
+ # - tapOn: "..."
1885
+ # - assertVisible: "..."
1886
+ # - stopApp
1887
+ # - launchApp # no clearState — the data must survive
1888
+ # - assertVisible: "..."
1889
+ YAML
1890
+
1891
+ pkg_set scripts.test:e2e="maestro test .maestro"
1892
+
1893
+ if command -v maestro >/dev/null 2>&1; then
1894
+ log "maestro flows in .maestro/ (appId ${app_id})"
1895
+ else
1896
+ log "maestro flows in .maestro/ (appId ${app_id})"
1897
+ warn "the maestro CLI is not installed — 'yarn test:e2e' needs it:"
1898
+ warn " curl -fsSL https://get.maestro.mobile.dev | bash"
1899
+ fi
1900
+ }
1901
+
1902
+ # Accessibility as an assertion, not a review note.
1903
+ #
1904
+ # Querying by role already fails a test when a control has no accessible
1905
+ # name. axe covers what a role query cannot see — contrast, duplicate
1906
+ # landmarks, orphaned labels, invalid ARIA — and it is mechanical, so a
1907
+ # machine should catch it on every run rather than a human at review time.
1908
+ #
1909
+ # axe-core directly rather than a wrapper: vitest-axe is 0.x and has not
1910
+ # published in over a year, and component tests already run in real Chromium,
1911
+ # which is the environment axe is built for. No adapter needed.
1912
+ setup_axe() {
1913
+ add_latest --dev axe-core
1914
+ mkdir -p src/test
1915
+
1916
+ cat > src/test/a11y.ts <<'TS'
1917
+ import axe, { type Result } from "axe-core";
1918
+ import { expect } from "vitest";
1919
+
1920
+ /**
1921
+ * Fails the test with a readable report if the given subtree has any
1922
+ * accessibility violation.
1923
+ *
1924
+ * Raw axe output is a deep object nobody reads, so violations are flattened
1925
+ * to one line each: the rule, the impact, and the element that broke it.
1926
+ */
1927
+ export async function expectNoA11yViolations(
1928
+ element: Element = document.body,
1929
+ ): Promise<void> {
1930
+ const results = await axe.run(element, {
1931
+ rules: {
1932
+ // Both need a whole document. A mounted component legitimately has
1933
+ // neither, and failing on that trains people to ignore the report.
1934
+ region: { enabled: false },
1935
+ "page-has-heading-one": { enabled: false },
1936
+ },
1937
+ });
1938
+
1939
+ const lines = results.violations.flatMap((v: Result) =>
1940
+ v.nodes.map(
1941
+ (n) => `[${v.impact ?? "unknown"}] ${v.id}: ${v.help}\n ${n.target.join(" ")}`,
1942
+ ),
1943
+ );
1944
+
1945
+ expect(lines, `accessibility violations:\n ${lines.join("\n ")}`).toEqual([]);
1946
+ }
1947
+ TS
1948
+ log "axe helper at src/test/a11y.ts"
1949
+ }
1950
+
1951
+ # The same check against the real, built page — where contrast, landmarks and
1952
+ # heading order actually exist. A component test cannot see any of those.
1953
+ setup_axe_e2e() {
1954
+ # playwright-core explicitly: @axe-core/playwright peers on it, and the
1955
+ # `playwright` package only pulls it in transitively — which leaves the peer
1956
+ # genuinely unmet rather than merely noisy.
1957
+ add_latest --dev @axe-core/playwright playwright-core
1958
+ cat > e2e/a11y.spec.ts <<'SPEC'
1959
+ import AxeBuilder from "@axe-core/playwright";
1960
+ import { expect, test } from "@playwright/test";
1961
+
1962
+ /**
1963
+ * Accessibility of the whole rendered page.
1964
+ *
1965
+ * Component tests check a mounted subtree. Only here do contrast against the
1966
+ * real background, landmark structure and heading order exist at all — and
1967
+ * those are the failures a screen reader user actually hits.
1968
+ */
1969
+ test("the page has no accessibility violations", async ({ page }) => {
1970
+ await page.goto("/");
1971
+
1972
+ const results = await new AxeBuilder({ page })
1973
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
1974
+ .analyze();
1975
+
1976
+ const lines = results.violations.flatMap((v) =>
1977
+ v.nodes.map((n) => `[${v.impact}] ${v.id}: ${v.help} — ${n.target.join(" ")}`),
1978
+ );
1979
+
1980
+ expect(lines, `accessibility violations:\n ${lines.join("\n ")}`).toEqual([]);
1981
+ });
1982
+ SPEC
1983
+ log "e2e/a11y.spec.ts (axe against the built page)"
1984
+ }
1985
+
1986
+ # The rules, made executable.
1987
+ #
1988
+ # A rule nothing checks is prose an agent may or may not follow. These are
1989
+ # the ones from `.claude/rules/` that can be decided mechanically with high
1990
+ # precision — deliberately not the ones that cannot. A check that guesses
1991
+ # produces false positives, and a gate people learn to override is worse than
1992
+ # no gate.
1993
+ write_policy_check_script() {
1994
+ local root; root="$(git rev-parse --show-toplevel)"
1995
+ [ -f "$root/scripts/policy-check.mjs" ] && return 0
1996
+ mkdir -p "$root/scripts"
1997
+ cat > "$root/scripts/policy-check.mjs" <<'JS'
1998
+ #!/usr/bin/env node
1999
+ /**
2000
+ * Mechanical enforcement of the rules in .claude/rules/.
2001
+ *
2002
+ * Only checks that can be decided with high precision live here. Authorization
2003
+ * on a route and PII in a log line cannot be — they stay review items in the
2004
+ * rules rather than becoming a check that cries wolf.
2005
+ *
2006
+ * Usage: node scripts/policy-check.mjs [--staged]
2007
+ */
2008
+ import { execFileSync } from "node:child_process";
2009
+ import { readFileSync } from "node:fs";
2010
+ import { relative } from "node:path";
2011
+
2012
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
2013
+ const stagedOnly = process.argv.includes("--staged");
2014
+
2015
+ const files = execFileSync(
2016
+ "git",
2017
+ stagedOnly
2018
+ ? ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]
2019
+ : ["ls-files"],
2020
+ { encoding: "utf8", cwd: root, maxBuffer: 64 * 1024 * 1024 },
2021
+ )
2022
+ .split("\n")
2023
+ .filter(Boolean)
2024
+ .filter((f) => /\.(ts|tsx|js|jsx|mjs|kt|swift)$/.test(f))
2025
+ .filter((f) => !/(^|\/)(node_modules|dist|build|coverage|\.next|android|ios)\//.test(f));
2026
+
2027
+ const findings = [];
2028
+ const report = (file, line, rule, message) =>
2029
+ findings.push({ file, line, rule, message });
2030
+
2031
+ /**
2032
+ * Whether React's compiler is enabled anywhere in this repo.
2033
+ *
2034
+ * Read, never assumed. React 19 memoizes automatically when it is on, and a
2035
+ * check that demanded `memo` on top of that would be asking for noise — the
2036
+ * same reason a library version is resolved rather than recalled.
2037
+ */
2038
+ const reactCompilerOn = (() => {
2039
+ const probes = ["app.json", "app.config.js", "next.config.js", "next.config.ts",
2040
+ "vite.config.ts", "babel.config.js", "package.json"];
2041
+ for (const rel of probes) {
2042
+ for (const dir of ["", "apps/web/", "apps/mobile/", "apps/api/"]) {
2043
+ try {
2044
+ const body = readFileSync(`${root}/${dir}${rel}`, "utf8");
2045
+ if (/reactCompiler|react-compiler/.test(body)) return true;
2046
+ } catch {
2047
+ // Absent is the common case and says nothing — not every probe
2048
+ // exists in every repo shape.
2049
+ }
2050
+ }
2051
+ }
2052
+ return false;
2053
+ })();
2054
+
2055
+ /** Line number of an index into the source. */
2056
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
2057
+
2058
+ /**
2059
+ * The same source with every comment, quoted string, regex literal and
2060
+ * template-literal text blanked to spaces. Offsets and line numbers survive,
2061
+ * so a finding still points at the right line.
2062
+ *
2063
+ * Without this the checks fire on their own subject matter. A test that
2064
+ * guards the test-id rule is written by putting `getByTestId` inside a
2065
+ * regex, and the rule then reports the guard as the violation it exists to
2066
+ * prevent — which is how a gate teaches people to disable it.
2067
+ *
2068
+ * Interpolation holes stay as code: `${` is what the interpolated-sql check
2069
+ * looks for, and the expression inside one can hold a real violation.
2070
+ */
2071
+ function codeOnly(src) {
2072
+ const out = src.split("");
2073
+ const blank = (from, to) => {
2074
+ for (let i = from; i < to && i < out.length; i++) if (out[i] !== "\n") out[i] = " ";
2075
+ };
2076
+ // What may precede a regex literal. After anything else a slash is division.
2077
+ const regexMayFollow =
2078
+ /[([{,;:=!&|?+\-*%~^<>]$|\b(return|typeof|case|in|of|new|delete|void|do|else|yield|await)$/;
2079
+
2080
+ let i = 0;
2081
+ while (i < src.length) {
2082
+ const c = src[i];
2083
+
2084
+ if (c === "/" && src[i + 1] === "/") {
2085
+ let j = src.indexOf("\n", i);
2086
+ if (j === -1) j = src.length;
2087
+ blank(i, j);
2088
+ i = j;
2089
+ continue;
2090
+ }
2091
+
2092
+ if (c === "/" && src[i + 1] === "*") {
2093
+ let j = src.indexOf("*/", i + 2);
2094
+ j = j === -1 ? src.length : j + 2;
2095
+ blank(i, j);
2096
+ i = j;
2097
+ continue;
2098
+ }
2099
+
2100
+ if (c === '"' || c === "'") {
2101
+ let j = i + 1;
2102
+ while (j < src.length) {
2103
+ if (src[j] === "\\") { j += 2; continue; }
2104
+ if (src[j] === c || src[j] === "\n") { j++; break; }
2105
+ j++;
2106
+ }
2107
+ blank(i + 1, j - 1);
2108
+ i = j;
2109
+ continue;
2110
+ }
2111
+
2112
+ if (c === "`") {
2113
+ let j = i + 1;
2114
+ let textStart = j;
2115
+ while (j < src.length) {
2116
+ if (src[j] === "\\") { j += 2; continue; }
2117
+ if (src[j] === "$" && src[j + 1] === "{") {
2118
+ blank(textStart, j);
2119
+ let depth = 1;
2120
+ j += 2;
2121
+ while (j < src.length && depth > 0) {
2122
+ if (src[j] === "{") depth++;
2123
+ else if (src[j] === "}") depth--;
2124
+ j++;
2125
+ }
2126
+ textStart = j;
2127
+ continue;
2128
+ }
2129
+ if (src[j] === "`") break;
2130
+ j++;
2131
+ }
2132
+ blank(textStart, j);
2133
+ i = j + 1;
2134
+ continue;
2135
+ }
2136
+
2137
+ if (c === "/" && regexMayFollow.test(src.slice(0, i).replace(/\s+$/, ""))) {
2138
+ let j = i + 1;
2139
+ let inClass = false;
2140
+ while (j < src.length) {
2141
+ const d = src[j];
2142
+ if (d === "\\") { j += 2; continue; }
2143
+ if (d === "\n") break;
2144
+ if (d === "[") inClass = true;
2145
+ else if (d === "]") inClass = false;
2146
+ else if (d === "/" && !inClass) { j++; break; }
2147
+ j++;
2148
+ }
2149
+ blank(i + 1, j - 1);
2150
+ i = j;
2151
+ continue;
2152
+ }
2153
+
2154
+ i++;
2155
+ }
2156
+ return out.join("");
2157
+ }
2158
+
2159
+ for (const file of files) {
2160
+ let src;
2161
+ try {
2162
+ src = readFileSync(`${root}/${file}`, "utf8");
2163
+ } catch {
2164
+ // Optional by design: a listed file may be gone or unreadable, and that
2165
+ // is not this script's business to report.
2166
+ continue;
2167
+ }
2168
+ const isTest = /\.(test|spec)\.|(^|\/)(__tests__|e2e)\//.test(file);
2169
+ // Scoped to the kit, and to files that actually render. The same two
2170
+ // patterns in a screen are a judgement call; in a component used inside a
2171
+ // list they are a defect. See performance.orchestra.md.
2172
+ const isKitComponent =
2173
+ /(^|\/)src\/components\//.test(file) && /\.(tsx|jsx)$/.test(file) && !isTest;
2174
+ const code = codeOnly(src);
2175
+
2176
+ // ── Comments ────────────────────────────────────────────────────────
2177
+ // comments.orchestra.md. Both are read from the real source, because
2178
+ // `code` has every comment blanked. One report per run of commented-out
2179
+ // lines — a fifty-line dead block is one finding, not fifty.
2180
+ //
2181
+ // "Parses as a statement" is a handful of shapes, not a parser: a
2182
+ // declaration, an import, a call or assignment ending in `;`, a control
2183
+ // keyword opening a block, a bare closing brace. English does not end a
2184
+ // line in `;` and does not open one with `const x =`, so the false
2185
+ // positives are rare enough to leave the escape hatch out.
2186
+ const commentedCode = [
2187
+ /^(const|let|var|val)\s+[\w$]+\s*[=:]/,
2188
+ /^import\s+(["'{*]|[\w$]+\s*(,|from\b))/,
2189
+ /^export\s+(const|let|var|function|class|default|async|interface|type)\b/,
2190
+ /^(if|for|while|switch)\s*\(.*\)\s*\{?\s*$/,
2191
+ /^\}\s*(else\b.*|catch\b.*|finally\b.*)?\{?\s*$/,
2192
+ /^(return|await|throw)\b.*;\s*$/,
2193
+ /^[\w$.]+\s*(\(.*\)|=[^=].*);\s*$/,
2194
+ ];
2195
+ // Spelled with escapes so this file does not report itself: the check
2196
+ // reads comments from the real source, and this is a comment.
2197
+ const marker = "TO\u0044O|FIX\u004DE|HA\u0043K|X\u0058X";
2198
+ const markerRe = new RegExp(`\\b(${marker})\\b(.*)$`);
2199
+ const directive = new RegExp(`^(eslint|biome-ignore|prettier-ignore|@ts-|MARK:|noinspection|swiftlint|ktlint|${marker})`);
2200
+ let inRun = false;
2201
+ src.split("\n").forEach((line, i) => {
2202
+ const m = line.match(/^\s*\/\/\s?(.*)$/);
2203
+ const text = m ? m[1].trim() : "";
2204
+ const dead =
2205
+ !!m && !directive.test(text) && !/:\/\//.test(text) &&
2206
+ commentedCode.some((re) => re.test(text));
2207
+ if (dead && !inRun) {
2208
+ report(file, i + 1, "commented-out-code",
2209
+ "delete it — the history is in git, and a reader cannot tell a draft from a deletion");
2210
+ }
2211
+ inRun = dead;
2212
+
2213
+ // A to-do marker is a tech_debt task that skipped the tracker. It passes
2214
+ // with the task beside it — `(#123)`, `(CU-86abc)` — or a link.
2215
+ const todo = line.match(markerRe);
2216
+ if (todo && !/^\s*\([^)]+\)|#\d+|:\/\//.test(todo[2])) {
2217
+ report(file, i + 1, "todo-without-task",
2218
+ "open the tech_debt task and put its id here, or do the work");
2219
+ }
2220
+ });
2221
+
2222
+ // ── Swallowed errors ────────────────────────────────────────────────
2223
+ // observability.orchestra.md: every catch either handles the error
2224
+ // meaningfully or reports it and rethrows. The one exception is a
2225
+ // genuinely optional path, and it carries a comment saying why.
2226
+ //
2227
+ // Brace-matched rather than regex-matched. `catch {}` on one line has no
2228
+ // newline to anchor a pattern on, so a regex demanding one either misses
2229
+ // the plainest swallowed error there is, or runs on to the next closing
2230
+ // brace and reports somebody else's code at the wrong line.
2231
+ for (const m of code.matchAll(/\bcatch\s*(?:\([^)]*\))?\s*\{/g)) {
2232
+ const open = m.index + m[0].length - 1;
2233
+ let depth = 1;
2234
+ let end = open + 1;
2235
+ while (end < code.length && depth > 0) {
2236
+ if (code[end] === "{") depth++;
2237
+ else if (code[end] === "}") depth--;
2238
+ end++;
2239
+ }
2240
+ const body = code.slice(open + 1, end - 1);
2241
+ // The escape hatch is a comment saying why failure is acceptable, and
2242
+ // comments are blanked in `code` — so read it from the real source.
2243
+ if (/\/\/|\/\*/.test(src.slice(open + 1, end - 1))) continue;
2244
+ // `exitCode` is here for the same reason `res.` is: in a CLI, a failure
2245
+ // exit code is how the failure leaves the process. A catch that tells the
2246
+ // user what happened and then exits non-zero has handled it.
2247
+ const meaningful =
2248
+ /\bthrow\b|\breject\b|captureException|Sentry|logger\.|report|res\.|exitCode|return\s+\S/.test(body);
2249
+ const onlyLogs = /^\s*console\.\w+\([^)]*\);?\s*$/.test(body);
2250
+ // In a test the failure is the subject: catching it and recording that
2251
+ // it happened is handling it, and the assertion that follows is the report.
2252
+ const handled = meaningful || (isTest && /\w\s*=[^=]/.test(body));
2253
+ if (body.trim() === "" || onlyLogs || !handled) {
2254
+ report(file, lineAt(code, m.index), "swallowed-error",
2255
+ body.trim() === "" ? "empty catch block" : "catch neither handles nor reports");
2256
+ }
2257
+ }
2258
+
2259
+ // ── Test-id queries ─────────────────────────────────────────────────
2260
+ // The suite should enforce accessibility, not route around it.
2261
+ if (isTest) {
2262
+ for (const m of code.matchAll(/\bgetByTestId|\bfindByTestId|\bqueryByTestId|onNodeWithTag/g)) {
2263
+ report(file, lineAt(code, m.index), "query-by-testid",
2264
+ "query by role or accessible name instead");
2265
+ }
2266
+ }
2267
+
2268
+ // ── A mocked database ───────────────────────────────────────────────
2269
+ // testing-standards.orchestra.md: mock it and you test your own mock.
2270
+ if (isTest) {
2271
+ for (const m of code.matchAll(/(?:vi|jest)\.mock\(\s*["'`]([^"'`]+)["'`]/g)) {
2272
+ if (/\b(db|database|prisma|drizzle|knex|pg|postgres|mongoose|sequelize|repository|repo)\b/i.test(m[1])) {
2273
+ report(file, lineAt(code, m.index), "mocked-database",
2274
+ `mocks ${m[1]} — use a real database in a container`);
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ // ── Interpolated SQL ────────────────────────────────────────────────
2280
+ // security.orchestra.md: parameterised queries only.
2281
+ for (const m of code.matchAll(/\.(query|execute|raw|unsafe)\s*\(\s*`[^`]*\$\{/g)) {
2282
+ report(file, lineAt(code, m.index), "interpolated-sql",
2283
+ "string-built SQL — use parameters");
2284
+ }
2285
+
2286
+ // ── A round trip per iteration ──────────────────────────────────────
2287
+ // performance.orchestra.md. The same defect whether the loop calls a
2288
+ // database, an HTTP API or the filesystem; naming it N+1 is a database
2289
+ // habit. Reads as ordinary code and costs a full round trip per row.
2290
+ //
2291
+ // `for await` is excluded — consuming an async iterable is the correct
2292
+ // way to stream, not a mistake. Tests are excluded because a suite
2293
+ // awaiting in sequence is usually setup, not a hot path.
2294
+ if (!isTest) {
2295
+ for (const m of code.matchAll(/\b(for|while)\s*(?:await\s*)?\(/g)) {
2296
+ if (/for\s*await/.test(m[0])) continue;
2297
+ // Past the header's own parentheses, then over the balanced body.
2298
+ let i = m.index + m[0].length;
2299
+ let depth = 1;
2300
+ while (i < code.length && depth > 0) {
2301
+ if (code[i] === "(") depth++;
2302
+ else if (code[i] === ")") depth--;
2303
+ i++;
2304
+ }
2305
+ const open = code.indexOf("{", i);
2306
+ if (open === -1 || open > i + 40) continue;
2307
+ depth = 1;
2308
+ let end = open + 1;
2309
+ while (end < code.length && depth > 0) {
2310
+ if (code[end] === "{") depth++;
2311
+ else if (code[end] === "}") depth--;
2312
+ end++;
2313
+ }
2314
+ const body = code.slice(open + 1, end - 1);
2315
+ if (!/\bawait\b/.test(body)) continue;
2316
+ // Sequential is genuinely right sometimes — an ordered write, a rate
2317
+ // limit, a cursor that depends on the previous page. A comment above
2318
+ // the loop saying which clears it; comments are blanked in `code`, so
2319
+ // it is read from the real source.
2320
+ const before = src.slice(Math.max(0, m.index - 200), m.index);
2321
+ if (/\/\/|\/\*/.test(before)) continue;
2322
+ report(file, lineAt(code, m.index), "await-in-loop",
2323
+ "a round trip per iteration — batch it, or say above the loop why sequential is required");
2324
+ }
2325
+ }
2326
+
2327
+ // ── An effect that starts a lifetime and never ends it ──────────────
2328
+ // performance.orchestra.md: whoever starts a lifetime ends it, in the same
2329
+ // place. A React effect is that place, and a listener, a timer or a
2330
+ // subscription started inside one with no cleanup returned outlives the
2331
+ // component — the one leak shape a script can see. Brace-matched like the
2332
+ // catch check, for the same reason. A comment inside the effect saying
2333
+ // why no cleanup is needed clears it; comments are blanked in `code`, so
2334
+ // it is read from the real source.
2335
+ if (/\.(tsx|jsx|ts|js)$/.test(file) && !isTest) {
2336
+ for (const m of code.matchAll(/\buse(?:Layout)?Effect\s*\(\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{/g)) {
2337
+ const open = m.index + m[0].length - 1;
2338
+ let depth = 1;
2339
+ let end = open + 1;
2340
+ while (end < code.length && depth > 0) {
2341
+ if (code[end] === "{") depth++;
2342
+ else if (code[end] === "}") depth--;
2343
+ end++;
2344
+ }
2345
+ const body = code.slice(open + 1, end - 1);
2346
+ const starts =
2347
+ /\b(?:addEventListener|setInterval|setTimeout|subscribe|addListener|on)\s*\(|\bnew\s+(?:WebSocket|EventSource|IntersectionObserver|ResizeObserver|MutationObserver)\b/.test(
2348
+ body,
2349
+ );
2350
+ if (!starts) continue;
2351
+ if (/^\s*return\b|[;{}]\s*return\b/m.test(body)) continue;
2352
+ if (/\/\/|\/\*/.test(src.slice(open + 1, end - 1))) continue;
2353
+ report(file, lineAt(code, m.index), "effect-without-cleanup",
2354
+ "the effect starts a listener, timer or subscription and returns no cleanup — return the function that stops it, or say inside the effect why none is needed");
2355
+ }
2356
+ }
2357
+
2358
+ // ── An array index as a key ─────────────────────────────────────────
2359
+ // performance.orchestra.md. A correctness bug before a performance one:
2360
+ // reorder or delete and React reuses the wrong element with the wrong
2361
+ // state. `key={item.id}` is the fix, never a counter.
2362
+ for (const m of code.matchAll(/\bkey=\{\s*(i|idx|index)\s*\}/g)) {
2363
+ report(file, lineAt(code, m.index), "array-index-key",
2364
+ `key={${m[1]}} — key on the item's identity, not its position`);
2365
+ }
2366
+
2367
+ // ── Kit components ──────────────────────────────────────────────────
2368
+ // A component in src/components/ renders inside lists and under parents
2369
+ // that re-render often, so these are decided once here rather than per
2370
+ // phase. Scoped to the kit on purpose: the same two patterns in a screen
2371
+ // are a judgement call, and a check that cries wolf gets switched off.
2372
+ if (isKitComponent) {
2373
+ for (const m of code.matchAll(/\bstyle=\{\{/g)) {
2374
+ report(file, lineAt(code, m.index), "inline-style-object",
2375
+ "new style object every render — StyleSheet.create, or memoize on the inputs");
2376
+ }
2377
+
2378
+ // Only when the compiler is off. React 19's compiler memoizes for you,
2379
+ // and asking for `memo` on top of it is asking for redundant noise —
2380
+ // read from the project rather than assumed, the same as a version.
2381
+ if (!reactCompilerOn) {
2382
+ for (const m of code.matchAll(
2383
+ /export\s+(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*[:=])/g,
2384
+ )) {
2385
+ const name = m[1] || m[2];
2386
+ const memoized = new RegExp(
2387
+ `memo\\(\\s*(?:function\\s+)?${name}\\b|(?:const|let)\\s+${name}\\s*=\\s*(?:React\\.)?memo\\(`,
2388
+ ).test(code);
2389
+ // The escape hatch is a comment saying why, read from the real
2390
+ // source because comments are blanked in `code`.
2391
+ const explained = /\/\/|\/\*/.test(src.slice(Math.max(0, m.index - 200), m.index));
2392
+ if (!memoized && !explained) {
2393
+ report(file, lineAt(code, m.index), "unmemoized-kit-component",
2394
+ `${name} is not memoized and the React compiler is off`);
2395
+ }
2396
+ }
2397
+ }
2398
+ }
2399
+ }
2400
+
2401
+ if (!findings.length) {
2402
+ console.log(` policy: ${files.length} file(s) clean`);
2403
+ process.exit(0);
2404
+ }
2405
+
2406
+ const byRule = new Map();
2407
+ for (const f of findings) {
2408
+ if (!byRule.has(f.rule)) byRule.set(f.rule, []);
2409
+ byRule.get(f.rule).push(f);
2410
+ }
2411
+
2412
+ console.error(`\n [x] ${findings.length} policy violation(s)\n`);
2413
+ for (const [rule, items] of byRule) {
2414
+ console.error(` ${rule}`);
2415
+ for (const i of items) console.error(` ${i.file}:${i.line} — ${i.message}`);
2416
+ console.error("");
2417
+ }
2418
+ console.error(" These are rules in .claude/rules/, not style preferences.");
2419
+ console.error(" Fix the code. Do not add an ignore.\n");
2420
+ process.exit(1);
2421
+ JS
2422
+ chmod +x "$root/scripts/policy-check.mjs"
2423
+ log "wrote scripts/policy-check.mjs"
2424
+ }
2425
+
2426
+ # The component kit, and the line that decides where a component comes from.
2427
+ #
2428
+ # Two worked examples, because the rule is a boundary and one side of it
2429
+ # teaches nothing:
2430
+ #
2431
+ # Button — a native <button>. The browser already supplies the role, the
2432
+ # keyboard behaviour, the disabled semantics and form
2433
+ # participation. A library here buys a dependency and nothing else.
2434
+ #
2435
+ # Select — react-aria-components. There is no HTML element for a listbox
2436
+ # that renders custom content, so a hand-rolled one has to
2437
+ # re-implement roving tabindex, typeahead, dismissal, focus
2438
+ # return and aria-activedescendant. axe runs as an assertion in
2439
+ # this stack, so that work fails the gate repeatedly instead of
2440
+ # once, at the library choice.
2441
+ setup_ui_primitives() {
2442
+ # $1 = "client" on a framework with server components. Both files below
2443
+ # carry an event handler or context, so on the Next App Router they are
2444
+ # client components — without the directive the build fails rather than
2445
+ # degrading, which at least fails loudly.
2446
+ local client="${1:-}"
2447
+ add_latest react-aria-components
2448
+ mkdir -p src/components/Button src/components/Select
2449
+
2450
+ # --- Button: the native side of the line -----------------------------
2451
+ cat > src/components/Button/Button.tsx <<'TSX'
2452
+ import type { ButtonHTMLAttributes, Ref } from "react";
2453
+ import type { Size } from "../../design";
2454
+
2455
+ /**
2456
+ * A button.
2457
+ *
2458
+ * Deliberately a native `<button>` with no library behind it. The element
2459
+ * already carries the role, keyboard activation, `disabled` semantics and
2460
+ * form submission — everything a primitive would re-supply.
2461
+ */
2462
+ export type ButtonVariant = "primary" | "secondary";
2463
+
2464
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
2465
+ /** Union, not booleans — two intents cannot be requested at once. */
2466
+ variant?: ButtonVariant;
2467
+ size?: Size;
2468
+ ref?: Ref<HTMLButtonElement>;
2469
+ }
2470
+
2471
+ const VARIANT: Record<ButtonVariant, string> = {
2472
+ primary: "bg-[var(--color-brand-900)] text-[var(--color-brand-50)]",
2473
+ secondary: "border border-current bg-transparent text-[var(--on-surface)]",
2474
+ };
2475
+
2476
+ const SIZE: Record<Size, string> = {
2477
+ sm: "px-3 py-1 text-sm",
2478
+ md: "px-4 py-2 text-base",
2479
+ lg: "px-5 py-2.5 text-lg",
2480
+ };
2481
+
2482
+ export function Button({
2483
+ variant = "primary",
2484
+ size = "md",
2485
+ className = "",
2486
+ type = "button",
2487
+ ...props
2488
+ }: ButtonProps) {
2489
+ return (
2490
+ <button
2491
+ // Not `type="submit"`. The HTML default submits the enclosing form,
2492
+ // which is the wrong behaviour for every button that is not the
2493
+ // submit button, and it is invisible until a form appears around one.
2494
+ type={type}
2495
+ className={[
2496
+ "inline-flex items-center justify-center rounded-[var(--radius-control)] font-medium",
2497
+ "transition-colors duration-[var(--duration-fast)]",
2498
+ // Never `outline-none` without a replacement — the focus ring is
2499
+ // the only thing a keyboard user has to locate themselves.
2500
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-900)]",
2501
+ "disabled:opacity-60",
2502
+ VARIANT[variant],
2503
+ SIZE[size],
2504
+ className,
2505
+ ]
2506
+ .filter(Boolean)
2507
+ .join(" ")}
2508
+ {...props}
2509
+ />
2510
+ );
2511
+ }
2512
+ TSX
2513
+
2514
+ cat > src/components/Button/index.ts <<'TS'
2515
+ export { Button } from "./Button";
2516
+ export type { ButtonProps, ButtonVariant } from "./Button";
2517
+ TS
2518
+
2519
+ cat > src/components/Button/Button.fixture.tsx <<'TSX'
2520
+ import { Button } from "./Button";
2521
+
2522
+ // react-cosmos fixture. The workbench catalogue, and the deterministic
2523
+ // render target a design comparison is taken against.
2524
+ export default {
2525
+ primary: <Button>Save</Button>,
2526
+ secondary: <Button variant="secondary">Cancel</Button>,
2527
+ disabled: <Button disabled>Save</Button>,
2528
+ };
2529
+ TSX
2530
+
2531
+ cat > src/components/Button/Button.test.tsx <<'TSX'
2532
+ import { page, userEvent } from "vitest/browser";
2533
+ import { render } from "vitest-browser-react";
2534
+ import { describe, expect, test, vi } from "vitest";
2535
+ import { expectNoA11yViolations } from "../../test/a11y";
2536
+ import { Button } from "./Button";
2537
+
2538
+ describe("Button", () => {
2539
+ test("is reachable by its accessible name and fires on click", async () => {
2540
+ const onClick = vi.fn();
2541
+ await render(<Button onClick={onClick}>Save</Button>);
2542
+
2543
+ // By role, never by test id: a <div onClick> would fail this line,
2544
+ // which is how the suite ends up enforcing accessibility rather than
2545
+ // review noticing it.
2546
+ await page.getByRole("button", { name: "Save" }).click();
2547
+
2548
+ expect(onClick).toHaveBeenCalledTimes(1);
2549
+ });
2550
+
2551
+ test("activates from the keyboard", async () => {
2552
+ const onClick = vi.fn();
2553
+ await render(<Button onClick={onClick}>Save</Button>);
2554
+
2555
+ await userEvent.tab();
2556
+ await userEvent.keyboard("{Enter}");
2557
+
2558
+ expect(onClick).toHaveBeenCalledTimes(1);
2559
+ });
2560
+
2561
+ test("disabled means disabled, not merely dimmed", async () => {
2562
+ const onClick = vi.fn();
2563
+ await render(
2564
+ <Button disabled onClick={onClick}>
2565
+ Save
2566
+ </Button>,
2567
+ );
2568
+
2569
+ await expect
2570
+ .element(page.getByRole("button", { name: "Save" }))
2571
+ .toBeDisabled();
2572
+ expect(onClick).not.toHaveBeenCalled();
2573
+ });
2574
+
2575
+ test("has no accessibility violations", async () => {
2576
+ await render(<Button>Save</Button>);
2577
+ await expectNoA11yViolations();
2578
+ });
2579
+ });
2580
+ TSX
2581
+
2582
+ # --- Select: the side of the line that needs a primitive -------------
2583
+ cat > src/components/Select/Select.tsx <<'TSX'
2584
+ import {
2585
+ Select as AriaSelect,
2586
+ Button,
2587
+ Label,
2588
+ ListBox,
2589
+ ListBoxItem,
2590
+ Popover,
2591
+ SelectValue,
2592
+ } from "react-aria-components";
2593
+ // Not React's `Key` — React 19 widened it to include bigint and the
2594
+ // collection API did not, so importing from "react" fails to compile here.
2595
+ import type { Key } from "react-aria-components";
2596
+
2597
+ /**
2598
+ * A select.
2599
+ *
2600
+ * Built on react-aria-components rather than by hand. `<select>` cannot
2601
+ * render custom option content, and the ARIA listbox pattern it would be
2602
+ * replaced with is a long list of behaviours — roving focus, typeahead,
2603
+ * Escape and outside-click dismissal, focus return to the trigger,
2604
+ * `aria-activedescendant`, screen-reader announcement of the selection.
2605
+ *
2606
+ * The primitive supplies the behaviour and no styling; every class below is
2607
+ * ours, referencing design tokens.
2608
+ */
2609
+ export interface SelectOption {
2610
+ id: Key;
2611
+ label: string;
2612
+ }
2613
+
2614
+ export interface SelectProps {
2615
+ label: string;
2616
+ options: readonly SelectOption[];
2617
+ selectedKey?: Key | null;
2618
+ defaultSelectedKey?: Key;
2619
+ onSelectionChange?: (key: Key | null) => void;
2620
+ placeholder?: string;
2621
+ isDisabled?: boolean;
2622
+ }
2623
+
2624
+ export function Select({ label, options, ...props }: SelectProps) {
2625
+ return (
2626
+ <AriaSelect className="flex flex-col gap-1" {...props}>
2627
+ <Label className="text-sm font-medium">{label}</Label>
2628
+ <Button
2629
+ className={[
2630
+ "flex items-center justify-between gap-2",
2631
+ "rounded-[var(--radius-control)] border border-current px-3 py-2 text-left",
2632
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-900)]",
2633
+ ].join(" ")}
2634
+ >
2635
+ <SelectValue />
2636
+ {/* Decorative, and an SVG rather than a text glyph on purpose: a
2637
+ character here lands in the button's textContent, so every
2638
+ assertion about the selected value would have to know about the
2639
+ arrow. The accessible name still comes from the label. */}
2640
+ <svg aria-hidden="true" focusable="false" width="10" height="6" viewBox="0 0 10 6">
2641
+ <path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
2642
+ </svg>
2643
+ </Button>
2644
+ <Popover className="min-w-[var(--trigger-width)] rounded-[var(--radius-control)] border border-current bg-[var(--surface)] text-[var(--on-surface)]">
2645
+ <ListBox className="max-h-60 overflow-auto p-1 outline-none">
2646
+ {options.map((option) => (
2647
+ <ListBoxItem
2648
+ key={String(option.id)}
2649
+ id={option.id}
2650
+ textValue={option.label}
2651
+ className="cursor-default rounded-[var(--radius-control)] px-3 py-2 outline-none data-[focused]:bg-[var(--color-brand-900)] data-[focused]:text-[var(--color-brand-50)]"
2652
+ >
2653
+ {option.label}
2654
+ </ListBoxItem>
2655
+ ))}
2656
+ </ListBox>
2657
+ </Popover>
2658
+ </AriaSelect>
2659
+ );
2660
+ }
2661
+ TSX
2662
+
2663
+ cat > src/components/Select/index.ts <<'TS'
2664
+ export { Select } from "./Select";
2665
+ export type { SelectOption, SelectProps } from "./Select";
2666
+ TS
2667
+
2668
+ cat > src/components/Select/Select.fixture.tsx <<'TSX'
2669
+ import { Select } from "./Select";
2670
+
2671
+ const OPTIONS = [
2672
+ { id: "apple", label: "Apple" },
2673
+ { id: "pear", label: "Pear" },
2674
+ { id: "quince", label: "Quince" },
2675
+ ];
2676
+
2677
+ export default {
2678
+ empty: <Select label="Fruit" options={OPTIONS} placeholder="Pick one" />,
2679
+ selected: <Select label="Fruit" options={OPTIONS} defaultSelectedKey="pear" />,
2680
+ };
2681
+ TSX
2682
+
2683
+ cat > src/components/Select/Select.test.tsx <<'TSX'
2684
+ import { page, userEvent } from "vitest/browser";
2685
+ import { render } from "vitest-browser-react";
2686
+ import { describe, expect, test, vi } from "vitest";
2687
+ import { expectNoA11yViolations } from "../../test/a11y";
2688
+ import { Select } from "./Select";
2689
+
2690
+ const OPTIONS = [
2691
+ { id: "apple", label: "Apple" },
2692
+ { id: "pear", label: "Pear" },
2693
+ { id: "quince", label: "Quince" },
2694
+ ];
2695
+
2696
+ describe("Select", () => {
2697
+ test("opens from the keyboard and exposes options by role", async () => {
2698
+ await render(<Select label="Fruit" options={OPTIONS} />);
2699
+
2700
+ await userEvent.tab();
2701
+ await userEvent.keyboard("{Enter}");
2702
+
2703
+ await expect.element(page.getByRole("listbox")).toBeInTheDocument();
2704
+ await expect
2705
+ .element(page.getByRole("option", { name: "Pear" }))
2706
+ .toBeInTheDocument();
2707
+ });
2708
+
2709
+ test("selecting an option reports the key and updates the trigger", async () => {
2710
+ const onSelectionChange = vi.fn();
2711
+ await render(
2712
+ <Select
2713
+ label="Fruit"
2714
+ options={OPTIONS}
2715
+ onSelectionChange={onSelectionChange}
2716
+ />,
2717
+ );
2718
+
2719
+ await page.getByRole("button", { name: /fruit/i }).click();
2720
+ await page.getByRole("option", { name: "Pear" }).click();
2721
+
2722
+ expect(onSelectionChange).toHaveBeenCalledWith("pear");
2723
+ // vitest browser mode matches the element's FULL text, unlike jest-dom
2724
+ // where a string is a substring match. That holds here only because the
2725
+ // chevron is an SVG and contributes no text content.
2726
+ await expect
2727
+ .element(page.getByRole("button", { name: /fruit/i }))
2728
+ .toHaveTextContent("Pear");
2729
+ });
2730
+
2731
+ // The behaviour a hand-rolled listbox gets wrong, and the reason this
2732
+ // component has a dependency behind it: dismissing has to put focus back
2733
+ // where it came from, or a keyboard user is returned to the top of the
2734
+ // document.
2735
+ test("Escape closes the listbox and returns focus to the trigger", async () => {
2736
+ await render(<Select label="Fruit" options={OPTIONS} />);
2737
+ const trigger = page.getByRole("button", { name: /fruit/i });
2738
+
2739
+ await trigger.click();
2740
+ await expect.element(page.getByRole("listbox")).toBeInTheDocument();
2741
+
2742
+ await userEvent.keyboard("{Escape}");
2743
+
2744
+ expect(page.getByRole("listbox").elements()).toHaveLength(0);
2745
+ await expect.element(trigger).toHaveFocus();
2746
+ });
2747
+
2748
+ test("has no accessibility violations, closed or open", async () => {
2749
+ await render(<Select label="Fruit" options={OPTIONS} />);
2750
+ await expectNoA11yViolations();
2751
+
2752
+ await page.getByRole("button", { name: /fruit/i }).click();
2753
+ await expect.element(page.getByRole("listbox")).toBeInTheDocument();
2754
+ await expectNoA11yViolations();
2755
+ });
2756
+ });
2757
+ TSX
2758
+
2759
+ # Select only, and deliberately. Verified on a real Next build:
2760
+ #
2761
+ # Select without the directive -> build fails, "createContext is not a
2762
+ # function". The primitive uses hooks and
2763
+ # context, so it cannot be a Server
2764
+ # Component.
2765
+ # Button without the directive -> builds. It is a native element with no
2766
+ # hooks, so it renders on the server and
2767
+ # ships no JavaScript. Marking it anyway
2768
+ # would push it into the client bundle to
2769
+ # buy nothing.
2770
+ #
2771
+ # A consumer passing onClick has to be a client component either way — that
2772
+ # boundary is the caller's, not this file's.
2773
+ if [ "$client" = "client" ]; then
2774
+ printf '"use client";\n\n%s\n' "$(cat src/components/Select/Select.tsx)" \
2775
+ > src/components/Select/Select.tsx
2776
+ log "Select marked \"use client\" (Button stays a server component)"
2777
+ fi
2778
+
2779
+ cat > src/components/README.md <<'MD'
2780
+ # Components
2781
+
2782
+ Where a component comes from is decided by one question:
2783
+
2784
+ > Does the browser already give me the role, the keyboard behaviour and the
2785
+ > focus management?
2786
+
2787
+ **Yes — write it.** `button`, `input`, `select`, `textarea`, `a`, `table`,
2788
+ `details`, `dialog` with `showModal()`, the Popover API. No library. See
2789
+ `Button/`.
2790
+
2791
+ **No — use the primitive.** Combobox, listbox with custom content, menu with
2792
+ typeahead, tabs, date picker, slider. These have no HTML element, so writing
2793
+ one means re-implementing the WAI-ARIA authoring practice for it. See
2794
+ `Select/`.
2795
+
2796
+ `react-aria-components` ships behaviour and no styling. Every class in these
2797
+ files is ours and every colour is a token. Nothing here is a theme.
2798
+
2799
+ A primitive is **not** an `src/integrations/` seam: it is not an SDK call or
2800
+ an HTTP API, so the third question in
2801
+ `.claude/skills/project-structure/SKILL.md` applies and it lives with the
2802
+ component that uses it. Its coverage comes from the interaction test above,
2803
+ which drives the real thing — a stronger check than a line-count gate.
2804
+ MD
2805
+ }
2806
+
2807
+ # A bundle size budget, as a ratchet rather than a number somebody picked.
2808
+ #
2809
+ # Same shape as the coverage ratchet: the committed baseline is whatever the
2810
+ # build actually produced, and it may go down but never up. That removes the
2811
+ # argument about what the right number is, and still fails the day a stray
2812
+ # import pulls in a date library.
2813
+ setup_bundle_budget() {
2814
+ local root rel
2815
+ root="$(git rev-parse --show-toplevel)"
2816
+ write_bundle_budget_script
2817
+ rel="$(node -e 'const p=require("path");process.stdout.write(p.relative(process.cwd(), process.argv[1]))' "$root/scripts/bundle-budget.mjs")"
2818
+ pkg_set scripts.build:budget="node ${rel}"
2819
+ log "bundle budget (ratchet)"
2820
+ }
2821
+
2822
+ write_bundle_budget_script() {
2823
+ local root; root="$(git rev-parse --show-toplevel)"
2824
+ [ -f "$root/scripts/bundle-budget.mjs" ] && return 0
2825
+ mkdir -p "$root/scripts"
2826
+ cat > "$root/scripts/bundle-budget.mjs" <<'JS'
2827
+ #!/usr/bin/env node
2828
+ /**
2829
+ * Bundle size, ratcheted.
2830
+ *
2831
+ * Not a number somebody picked: the committed baseline is whatever the build
2832
+ * actually produced. It may fall, and it may not rise. Nobody has to defend
2833
+ * "why 180kB", and the day a stray import pulls in a date library the build
2834
+ * still fails.
2835
+ *
2836
+ * Measured gzipped, because that is what a browser downloads. zlib is in the
2837
+ * standard library — this needs no dependency.
2838
+ *
2839
+ * Run from the app directory, after the build.
2840
+ */
2841
+ import { gzipSync } from "node:zlib";
2842
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2843
+ import { join } from "node:path";
2844
+
2845
+ const app = process.cwd();
2846
+ const OUT_DIRS = ["dist", "build", ".next/static", "out"];
2847
+ const outDir = OUT_DIRS.map((d) => join(app, d)).find((d) => existsSync(d));
2848
+
2849
+ if (!outDir) {
2850
+ console.log(" bundle budget: no build output found — run the build first");
2851
+ process.exit(0);
2852
+ }
2853
+
2854
+ /** Every javascript and css asset the browser would actually fetch. */
2855
+ function assets(dir) {
2856
+ const found = [];
2857
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2858
+ const full = join(dir, entry.name);
2859
+ if (entry.isDirectory()) found.push(...assets(full));
2860
+ else if (/\.(js|mjs|css)$/.test(entry.name) && !/\.map$/.test(entry.name)) found.push(full);
2861
+ }
2862
+ return found;
2863
+ }
2864
+
2865
+ const files = assets(outDir);
2866
+ if (!files.length) {
2867
+ console.log(" bundle budget: build output has no js or css");
2868
+ process.exit(0);
2869
+ }
2870
+
2871
+ const total = files.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0);
2872
+ const kb = (n) => `${(n / 1024).toFixed(1)}kB`;
2873
+
2874
+ const baselinePath = join(app, ".bundle-budget.json");
2875
+ const baseline = existsSync(baselinePath)
2876
+ ? JSON.parse(readFileSync(baselinePath, "utf8"))
2877
+ : null;
2878
+
2879
+ // A few kB of noise between builds is normal — hashed filenames, a changed
2880
+ // build id. Failing on that would train people to re-record the baseline.
2881
+ const TOLERANCE = 1024 * 2;
2882
+
2883
+ if (!baseline) {
2884
+ writeFileSync(baselinePath, `${JSON.stringify({ gzipBytes: total }, null, 2)}\n`);
2885
+ console.log(` bundle budget: baseline recorded at ${kb(total)} gzipped (${files.length} files)`);
2886
+ process.exit(0);
2887
+ }
2888
+
2889
+ const before = baseline.gzipBytes;
2890
+ const delta = total - before;
2891
+
2892
+ if (delta > TOLERANCE) {
2893
+ console.error(
2894
+ `\n [x] bundle grew ${kb(delta)} — ${kb(before)} to ${kb(total)} gzipped\n\n` +
2895
+ " If the growth is deliberate, say so and re-record:\n" +
2896
+ " rm .bundle-budget.json && yarn build && yarn build:budget\n" +
2897
+ " If it is not, something was imported that should not have been.\n",
2898
+ );
2899
+ process.exit(1);
2900
+ }
2901
+
2902
+ if (delta < 0) {
2903
+ writeFileSync(baselinePath, `${JSON.stringify({ gzipBytes: total }, null, 2)}\n`);
2904
+ console.log(` bundle budget: ${kb(before)} -> ${kb(total)} gzipped, baseline lowered`);
2905
+ } else {
2906
+ console.log(` bundle budget: ${kb(total)} gzipped (baseline ${kb(before)})`);
2907
+ }
2908
+ JS
2909
+ chmod +x "$root/scripts/bundle-budget.mjs"
2910
+ log "wrote scripts/bundle-budget.mjs"
2911
+ }
2912
+
2913
+ # The check that runs against the deployed thing, not against localhost.
2914
+ #
2915
+ # Railway's healthcheck already gates promotion on /ready. This is the layer
2916
+ # after it: a readiness probe can answer 200 while a route 500s, an asset
2917
+ # 404s, or a migration half-applied. It is also what decides whether the
2918
+ # release is rolled back.
2919
+ write_smoke_script() {
2920
+ local root; root="$(git rev-parse --show-toplevel)"
2921
+ [ -f "$root/scripts/smoke.mjs" ] && return 0
2922
+ mkdir -p "$root/scripts"
2923
+ cat > "$root/scripts/smoke.mjs" <<'JS'
2924
+ #!/usr/bin/env node
2925
+ /**
2926
+ * Post-deploy smoke test against a real URL.
2927
+ *
2928
+ * Deliberately tiny and dependency-free: it runs on a runner right after a
2929
+ * deploy, and anything it needs to install is another thing that can fail
2930
+ * while production is already live.
2931
+ *
2932
+ * Usage: node scripts/smoke.mjs https://api.example.com
2933
+ */
2934
+ const base = (process.argv[2] || process.env.DEPLOY_URL || "").replace(/\/$/, "");
2935
+ if (!base) {
2936
+ console.error(" [x] no URL given — pass one or set DEPLOY_URL");
2937
+ process.exit(1);
2938
+ }
2939
+
2940
+ /**
2941
+ * A deploy is not instant even after the healthcheck passes: DNS and the
2942
+ * edge take a moment. Retry with a bounded backoff rather than failing on
2943
+ * the first refused connection and rolling back a release that was fine.
2944
+ */
2945
+ async function fetchWithRetry(url, { attempts = 6, waitMs = 5000 } = {}) {
2946
+ let lastError;
2947
+ for (let i = 1; i <= attempts; i++) {
2948
+ try {
2949
+ const res = await fetch(url, { redirect: "manual" });
2950
+ // A 5xx during rollout is worth one more look; a 4xx is our own bug
2951
+ // and retrying only delays the bad news.
2952
+ if (res.status < 500 || i === attempts) return res;
2953
+ lastError = new Error(`status ${res.status}`);
2954
+ } catch (error) {
2955
+ // Held, not swallowed: each attempt's error loses to the next one and
2956
+ // the last is rethrown after the loop. A refused connection mid-rollout
2957
+ // is the expected case this function exists to retry through.
2958
+ lastError = error;
2959
+ }
2960
+ console.log(` attempt ${i}/${attempts} not ready yet — waiting ${waitMs / 1000}s`);
2961
+ await new Promise((r) => setTimeout(r, waitMs));
2962
+ }
2963
+ throw lastError;
2964
+ }
2965
+
2966
+ const checks = [
2967
+ {
2968
+ name: "readiness reports every dependency reachable",
2969
+ async run() {
2970
+ const res = await fetchWithRetry(`${base}/ready`);
2971
+ if (res.status !== 200) throw new Error(`/ready returned ${res.status}`);
2972
+ const body = await res.json();
2973
+ const failed = (body.checks ?? []).filter((c) => !c.ok);
2974
+ if (failed.length) {
2975
+ throw new Error(`dependencies down: ${failed.map((c) => c.name).join(", ")}`);
2976
+ }
2977
+ },
2978
+ },
2979
+ {
2980
+ name: "liveness answers",
2981
+ async run() {
2982
+ const res = await fetchWithRetry(`${base}/health`);
2983
+ if (res.status !== 200) throw new Error(`/health returned ${res.status}`);
2984
+ },
2985
+ },
2986
+ ];
2987
+
2988
+ // Add the flows this product exists for. A smoke suite that only proves the
2989
+ // process started is a smoke suite that has never caught anything.
2990
+
2991
+ let failed = 0;
2992
+ for (const check of checks) {
2993
+ try {
2994
+ await check.run();
2995
+ console.log(` [+] ${check.name}`);
2996
+ } catch (error) {
2997
+ // Reported and counted, not swallowed: one failing check must not stop the
2998
+ // rest from running, and a non-zero `failed` exits 1 below — which is what
2999
+ // triggers the rollback.
3000
+ failed++;
3001
+ console.error(` [x] ${check.name}\n ${error.message}`);
3002
+ }
3003
+ }
3004
+
3005
+ if (failed) {
3006
+ console.error(`\n ${failed} smoke check(s) failed against ${base} — rolling back\n`);
3007
+ process.exit(1);
3008
+ }
3009
+ console.log(`\n smoke passed against ${base}\n`);
3010
+ JS
3011
+ chmod +x "$root/scripts/smoke.mjs"
3012
+ log "wrote scripts/smoke.mjs"
3013
+ }
3014
+
3015
+ # The last thing a scaffold does: prove it produced clean work.
3016
+ #
3017
+ # Reports and fails. It deliberately does not fix anything — every
3018
+ # deterministic fix already happened during setup, and changing versions
3019
+ # *after* the verification gate ran would leave a repo whose gate passed
3020
+ # against different versions than the ones committed. A scaffold that quietly
3021
+ # adjusts itself at the end is one whose output nobody can reason about.
3022
+ #
3023
+ # When this fails, the fix belongs in the stack, not in the project.
3024
+ assert_scaffold_clean() {
3025
+ local problems=0
3026
+ local audit_checked=1
3027
+
3028
+ # ── Genuinely unmet peer dependencies ───────────────────────────────
3029
+ # Yarn marks optional, unprovided peers with ✓ and real breakage with ✘.
3030
+ # Only the second kind is a problem; treating the first as one would make
3031
+ # this check noise, and noise gets disabled.
3032
+ #
3033
+ # And only the ones we can do something about. "workspace:. doesn't provide
3034
+ # X" is ours — we forgot to install something. "@sentry/node doesn't provide
3035
+ # X to @sentry/node-core" is a vendor's own manifest, unfixable from here,
3036
+ # and failing a scaffold on it produces a gate nobody can satisfy, which is
3037
+ # a gate that gets disabled.
3038
+ local unmet ours theirs
3039
+ unmet="$(yarn explain peer-requirements 2>/dev/null | grep '✘' || true)"
3040
+ ours="$(printf '%s\n' "$unmet" | grep '@workspace:' || true)"
3041
+ theirs="$(printf '%s\n' "$unmet" | grep '✘' | grep -v '@workspace:' || true)"
3042
+
3043
+ if [ -n "$ours" ]; then
3044
+ warn "peer dependencies this app should provide and does not:"
3045
+ printf '%s\n' "$ours" | sed 's/^/ /' >&2
3046
+ problems=$((problems + 1))
3047
+ fi
3048
+ if [ -n "$theirs" ]; then
3049
+ # Reported, never fatal: worth knowing which vendor ships a broken
3050
+ # manifest, not worth blocking on something we cannot fix.
3051
+ warn "unmet peers inside dependencies (vendor manifests, not ours):"
3052
+ printf '%s\n' "$theirs" | sed 's/^/ /' >&2
3053
+ fi
3054
+
3055
+ # ── Known vulnerabilities ───────────────────────────────────────────
3056
+ # CI blocks a critical on every PR. Finding one only after the first push
3057
+ # means the repo was born vulnerable and nobody said so.
3058
+ #
3059
+ # Run once and read the output, because a non-zero exit means two different
3060
+ # things and only one of them is a vulnerability. When the registry does not
3061
+ # answer, this used to print "a critical vulnerability is present in a
3062
+ # freshly scaffolded app" and fail the scaffold — a claim nobody had
3063
+ # observed, about a check that never ran. Saying unverified is the rule
3064
+ # everywhere else here; it applies to bad news too.
3065
+ local audit_out audit_rc=0
3066
+ audit_out="$(yarn npm audit --severity critical 2>&1)" || audit_rc=$?
3067
+
3068
+ if [ "$audit_rc" -ne 0 ]; then
3069
+ if printf '%s' "$audit_out" | grep -qiE 'RequestError|Timeout awaiting|ETIMEDOUT|ENOTFOUND|ECONNRESET|EAI_AGAIN|socket hang up'; then
3070
+ # Not fatal. An unreachable registry is not a vulnerability, and CI runs
3071
+ # the same audit on every pull request — this is the earlier of two
3072
+ # chances, not the only one.
3073
+ warn "could not check for advisories — the registry did not answer:"
3074
+ printf '%s\n' "$audit_out" | head -3 | sed 's/^/ /' >&2
3075
+ audit_checked=0
3076
+ else
3077
+ warn "a critical vulnerability is present in a freshly scaffolded app:"
3078
+ printf '%s\n' "$audit_out" | head -20 | sed 's/^/ /' >&2
3079
+ problems=$((problems + 1))
3080
+ fi
3081
+ fi
3082
+
3083
+ if [ "$problems" -gt 0 ]; then
3084
+ warn ""
3085
+ warn "This scaffold is not clean. Fix it in .orchestra/stacks/, not here —"
3086
+ warn "otherwise the next project meets the same thing."
3087
+ return 1
3088
+ fi
3089
+
3090
+ if [ "$audit_checked" -eq 1 ]; then
3091
+ log "scaffold is clean: peers satisfied, no critical advisories"
3092
+ else
3093
+ log "scaffold is clean: peers satisfied. advisories unchecked — see above"
3094
+ fi
3095
+ }