agentic-team 0.0.0-stage → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1139 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +401 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +277 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +438 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +870 -0
- package/template/agents/milestone-reviewer.md +63 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +438 -0
- package/template/agents/reviewer.md +301 -0
- package/template/agents/scaffolder.md +180 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +55 -0
- package/template/commands/hotfix.md +12 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +61 -0
- package/template/commands/setup.md +14 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +73 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +153 -0
- package/template/hooks/enforce-role.sh +175 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +34 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +170 -0
- package/template/rules/observability.orchestra.md +118 -0
- package/template/rules/performance.orchestra.md +186 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +213 -0
- package/template/rules/role-boundaries.orchestra.md +117 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +214 -0
- package/template/rules/tracker-authority.orchestra.md +190 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +44 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- 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
|
+
}
|