@spardutti/claude-skills 2.9.0 → 2.11.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/README.md +57 -5
- package/lib/setup-hook.mjs +213 -52
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,8 +174,13 @@ It registers in `.claude/settings.json`:
|
|
|
174
174
|
### Automatic Verification (Gauntlet)
|
|
175
175
|
|
|
176
176
|
The same install adds `gauntlet.sh` — a `Stop` hook that runs the cheap, deterministic
|
|
177
|
-
gates on your changed files every time Claude finishes a turn. Green is silent; red
|
|
178
|
-
|
|
177
|
+
gates on your changed files every time Claude finishes a turn. Green is silent; red returns
|
|
178
|
+
`{"decision":"continue"}` so the turn doesn't end, handing the failure back to Claude to
|
|
179
|
+
fix before you ever see it.
|
|
180
|
+
|
|
181
|
+
Keep the gates fast for a reason beyond patience: a `command` hook that reaches its timeout
|
|
182
|
+
(600s by default) is cancelled and its **output discarded**, so the turn ends as if the hook
|
|
183
|
+
never ran. A stalled gate is a silent pass, not a block.
|
|
179
184
|
|
|
180
185
|
Only fast gates live here (target: under ~30s). Mutation testing and deep review stay
|
|
181
186
|
manual — they belong to `/test-review` and `/deep-review`, not to every turn.
|
|
@@ -197,12 +202,50 @@ It runs a skip ladder first, cheapest check first, and quits at the first "no":
|
|
|
197
202
|
|
|
198
203
|
1. no changed files (planning turns, questions)
|
|
199
204
|
2. no changed **code** files (docs/config only)
|
|
200
|
-
3. this exact diff already passed — the hash of a green run is cached per session
|
|
201
|
-
4. no
|
|
205
|
+
3. this exact diff already passed — the hash of a green run is cached per session and repo
|
|
206
|
+
4. no gates detected for this repo
|
|
202
207
|
|
|
203
208
|
Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
|
|
204
209
|
|
|
205
|
-
|
|
210
|
+
Every run records why it ended in `/tmp/claude-gauntlet-<session>.why`, so a green run
|
|
211
|
+
and a skipped one are told apart without re-running the hook. `GAUNTLET_DEBUG=1` prints
|
|
212
|
+
the same line to stderr:
|
|
213
|
+
|
|
214
|
+
```text
|
|
215
|
+
gauntlet: green: gates passed
|
|
216
|
+
gauntlet: skipped: no changed code files (docs/config only)
|
|
217
|
+
gauntlet: green: gates passed, but the runner matched 0 test files — the changed code was never executed
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
That last one is the false green worth knowing about. A test runner given files it has no
|
|
221
|
+
tests for exits 0 and looks exactly like a pass — in a monorepo whose vitest workspace
|
|
222
|
+
excludes a package, **no** change in that package can ever be covered. The hook names it
|
|
223
|
+
instead of reporting green. Set `GAUNTLET_REQUIRE_TESTS=1` to make it block instead.
|
|
224
|
+
|
|
225
|
+
The stack is auto-detected: vitest / jest / pytest for tests, and for typechecking the
|
|
226
|
+
repo's own `typecheck` script if it has one, else bare `tsc --noEmit` or `mypy`. The script
|
|
227
|
+
is preferred because a workspace root `tsconfig.json` is often solution-style, where
|
|
228
|
+
`tsc --noEmit` does not mean what the repo's `tsc -b` means.
|
|
229
|
+
|
|
230
|
+
**Monorepos work, including the ones with nothing at the root.** Detection collects every
|
|
231
|
+
gate it finds rather than stopping at the first, and each gate carries the file pattern it
|
|
232
|
+
applies to — so a JS + Python repo gets four gates, and a turn that touched only `.py` files
|
|
233
|
+
runs the Python two and skips the JS two entirely. Typechecks always run before tests, and
|
|
234
|
+
the first red stops the rest.
|
|
235
|
+
|
|
236
|
+
When the root has no manifest, detection looks two levels down for `package.json` /
|
|
237
|
+
`pyproject.toml` / `pytest.ini` — the `web/` + `api/`, `apps/*`, `packages/*` layouts — and
|
|
238
|
+
scopes each gate to that folder's files, running the command from inside it. Nested gates
|
|
239
|
+
are only added for the kind the root did not already provide, so a repo that works today
|
|
240
|
+
keeps working.
|
|
241
|
+
|
|
242
|
+
Setting `GAUNTLET_TEST` or `GAUNTLET_TYPECHECK` switches the repo to explicit mode:
|
|
243
|
+
auto-detection is off and only what you set runs.
|
|
244
|
+
|
|
245
|
+
**A missing tool is a skip, not a red.** A fresh clone with no `node_modules`, or a venv
|
|
246
|
+
without pytest, would otherwise fail every gate and block every turn — so the hook reports
|
|
247
|
+
`skipped: node_modules is missing — run your install first` and gets out of the way. Only
|
|
248
|
+
a gate that actually ran and actually failed blocks. Override anything in
|
|
206
249
|
a config file — two are read, in this order, and the project's wins key by key:
|
|
207
250
|
|
|
208
251
|
```text
|
|
@@ -215,6 +258,8 @@ GAUNTLET_OFF=1 # disable entirely
|
|
|
215
258
|
GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
|
|
216
259
|
GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
|
|
217
260
|
GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
|
|
261
|
+
GAUNTLET_DEBUG=1 # print the outcome to stderr every run
|
|
262
|
+
GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test files
|
|
218
263
|
```
|
|
219
264
|
|
|
220
265
|
Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
|
|
@@ -259,6 +304,8 @@ commands/ Slash commands installed to .claude/commands/
|
|
|
259
304
|
agents/ Subagent definitions — commands declare which they need via requires-agents
|
|
260
305
|
scripts/ validate-skills.mjs — checks skill length caps and reference integrity
|
|
261
306
|
gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
|
|
307
|
+
gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
|
|
308
|
+
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
262
309
|
cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
|
|
263
310
|
.husky/ pre-push hook running the skill validator
|
|
264
311
|
package.json Private dev-tooling package (claude-skills-dev) — not the published one
|
|
@@ -272,6 +319,11 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
|
|
|
272
319
|
- `SKILL.md` ≤ 350 lines; reference files ≤ 500 and need a `## Contents` TOC past 100 lines.
|
|
273
320
|
- References are one level deep — `SKILL.md` links them, they don't link each other.
|
|
274
321
|
- `npm run validate-skills` enforces this; it also runs on `pre-push`.
|
|
322
|
+
- Changing `scripts/gauntlet.sh`? Run `bash scripts/gauntlet-selftest.sh` (also on `pre-push`),
|
|
323
|
+
and run `bash scripts/gauntlet-survey.sh ~/projects` before releasing. Detection is where
|
|
324
|
+
this hook keeps breaking, because real repos are shaped in ways invented test repos aren't —
|
|
325
|
+
the survey is a dry run that reports what each of your repos would get without executing
|
|
326
|
+
anything. Read the rows that say `nothing`.
|
|
275
327
|
|
|
276
328
|
To get the validator on **every turn** instead of only on push, drop this into
|
|
277
329
|
this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -161,8 +161,15 @@ exit 0
|
|
|
161
161
|
const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
|
|
162
162
|
# gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
|
|
163
163
|
#
|
|
164
|
-
# Runs when Claude finishes a turn. Silent when green.
|
|
165
|
-
#
|
|
164
|
+
# Runs when Claude finishes a turn. Silent when green. On a red gate it returns
|
|
165
|
+
# {"decision":"continue"} so the turn does not end, handing the failure back to
|
|
166
|
+
# Claude to fix before the user sees it.
|
|
167
|
+
#
|
|
168
|
+
# Two things the hook contract makes non-negotiable:
|
|
169
|
+
# - stop_hook_active must short-circuit, or a red gate loops forever.
|
|
170
|
+
# - A hook that hits its timeout (600s default for command hooks) is cancelled
|
|
171
|
+
# and its output discarded, so the turn ends as if the hook never ran. Keep
|
|
172
|
+
# the gates fast; that is a silent pass, not a block.
|
|
166
173
|
#
|
|
167
174
|
# Only cheap gates belong here (target: under ~30s). Mutation testing, deep
|
|
168
175
|
# review, and the spec checklist are the /gauntlet command's job, not this hook's.
|
|
@@ -171,15 +178,23 @@ const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
|
|
|
171
178
|
# 1. no changed files
|
|
172
179
|
# 2. no changed *code* files (docs/config only)
|
|
173
180
|
# 3. this exact diff already passed
|
|
174
|
-
# 4. no
|
|
181
|
+
# 4. no gates detected for this repo
|
|
175
182
|
#
|
|
176
183
|
# Config, both optional and both KEY=value files that get sourced in this order:
|
|
177
184
|
# ~/.claude/gauntlet.conf your defaults for every project
|
|
178
185
|
# <project>/.claude/gauntlet.conf overrides for this one; wins key by key
|
|
179
186
|
# GAUNTLET_OFF=1 disable entirely
|
|
180
|
-
# GAUNTLET_TYPECHECK="cmd"
|
|
181
|
-
# GAUNTLET_TEST="cmd"
|
|
187
|
+
# GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
|
|
188
|
+
# GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
|
|
182
189
|
# GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
|
|
190
|
+
# GAUNTLET_DEBUG=1 print the outcome to stderr on every run
|
|
191
|
+
# GAUNTLET_REQUIRE_TESTS=1 block when a runner matched 0 test files
|
|
192
|
+
#
|
|
193
|
+
# Setting either command switches the whole repo to explicit mode — auto-detection
|
|
194
|
+
# is off, and only what you set runs.
|
|
195
|
+
#
|
|
196
|
+
# Every run records why it ended in /tmp/claude-gauntlet-<session>.why, so a green
|
|
197
|
+
# run and a skipped one are told apart without re-running the hook.
|
|
183
198
|
|
|
184
199
|
set -uo pipefail
|
|
185
200
|
|
|
@@ -190,11 +205,24 @@ case "$INPUT" in
|
|
|
190
205
|
*'"stop_hook_active":true'*) exit 0 ;;
|
|
191
206
|
esac
|
|
192
207
|
|
|
208
|
+
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
|
|
209
|
+
| sed 's/"session_id":"//; s/"$//')
|
|
210
|
+
if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
|
|
211
|
+
MARKER="/tmp/claude-gauntlet-$SESSION_ID"
|
|
212
|
+
WHY="$MARKER.why"
|
|
213
|
+
|
|
214
|
+
# Records why this run ended, so "green" and "never ran" stop looking identical.
|
|
215
|
+
quit() {
|
|
216
|
+
printf '%s\\n' "$1" > "$WHY" 2>/dev/null
|
|
217
|
+
if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: %s\\n' "$1" >&2; fi
|
|
218
|
+
exit 0
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
|
|
194
222
|
if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
|
|
195
|
-
cd "$PROJECT_DIR" 2>/dev/null ||
|
|
223
|
+
cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
|
|
196
224
|
|
|
197
|
-
git rev-parse --git-dir >/dev/null 2>&1 ||
|
|
225
|
+
git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
|
|
198
226
|
|
|
199
227
|
GAUNTLET_OFF=""
|
|
200
228
|
GAUNTLET_TYPECHECK="__auto__"
|
|
@@ -203,78 +231,203 @@ GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|
|
|
|
203
231
|
# Global defaults first, then the project's on top — project wins, key by key.
|
|
204
232
|
if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
|
|
205
233
|
if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
|
|
206
|
-
if [ -n "$GAUNTLET_OFF" ]; then
|
|
234
|
+
if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
|
|
207
235
|
|
|
208
236
|
# ---------------------------------------------------------------- skip rule 1
|
|
209
237
|
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
210
238
|
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
211
|
-
if [ -z "$CHANGED" ]; then
|
|
239
|
+
if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
|
|
212
240
|
|
|
213
241
|
# ---------------------------------------------------------------- skip rule 2
|
|
214
|
-
|
|
215
|
-
if [ -z "$
|
|
242
|
+
FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
|
|
243
|
+
if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
244
|
+
quit "skipped: no changed code files (docs/config only)"
|
|
245
|
+
fi
|
|
216
246
|
|
|
217
247
|
# ---------------------------------------------------------------- skip rule 3
|
|
218
|
-
|
|
248
|
+
# The repo path is part of the hash: one session can touch several repos, and two
|
|
249
|
+
# of them can legitimately hold an identical diff.
|
|
250
|
+
DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
|
|
219
251
|
| git hash-object --stdin 2>/dev/null )
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then exit 0; fi
|
|
252
|
+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \\
|
|
253
|
+
&& [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
254
|
+
quit "skipped: this diff already passed"
|
|
255
|
+
fi
|
|
225
256
|
|
|
226
257
|
# ---------------------------------------------------------------- skip rule 4
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
# A monorepo can hold more than one stack, so detection collects every gate it
|
|
259
|
+
# finds rather than stopping at the first. Each gate carries the file pattern it
|
|
260
|
+
# applies to, and is skipped entirely when the diff touched none of those files —
|
|
261
|
+
# so a Python-only change never runs the JS suite, and vice versa.
|
|
262
|
+
TC_GATES=""
|
|
263
|
+
TEST_GATES=""
|
|
264
|
+
SKIP_NOTE=""
|
|
265
|
+
|
|
266
|
+
add_gate() { # kind (typecheck|tests), file regex, command
|
|
267
|
+
LINE=$(printf '%s\\t%s\\t%s' "$1" "$2" "$3")
|
|
268
|
+
if [ "$1" = "typecheck" ]; then
|
|
269
|
+
TC_GATES="$TC_GATES$LINE
|
|
270
|
+
"
|
|
271
|
+
else
|
|
272
|
+
TEST_GATES="$TEST_GATES$LINE
|
|
273
|
+
"
|
|
274
|
+
fi
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
|
|
278
|
+
|
|
279
|
+
# Builds the gates for one directory. "" is the repo root; anything else is a
|
|
280
|
+
# nested project, and its gates match only files under that path. Commands run
|
|
281
|
+
# from inside the directory, which is why the file list is passed as absolute
|
|
282
|
+
# paths — a relative path would not survive the cd.
|
|
283
|
+
add_dir_gates() {
|
|
284
|
+
d="$1"
|
|
285
|
+
if [ -n "$d" ]; then pre="^$d/.*\\\\." ; run="cd $d && " ; else pre='\\.' ; run="" ; fi
|
|
286
|
+
have_tc=""; have_test=""
|
|
287
|
+
[ -n "$d" ] && [ -n "\${ROOT_TC:-}" ] && have_tc="skip"
|
|
288
|
+
[ -n "$d" ] && [ -n "\${ROOT_TEST:-}" ] && have_test="skip"
|
|
289
|
+
|
|
290
|
+
if [ -f "$d\${d:+/}package.json" ]; then
|
|
291
|
+
pkg="$d\${d:+/}package.json"
|
|
292
|
+
if [ -z "$have_tc" ]; then
|
|
293
|
+
if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
|
|
294
|
+
add_gate typecheck "$pre($JS_EXT)\\$" "\${run}npm run --silent typecheck"
|
|
295
|
+
elif [ -f "$d\${d:+/}tsconfig.json" ]; then
|
|
296
|
+
add_gate typecheck "$pre(ts|tsx)\\$" "\${run}npx --no-install tsc --noEmit"
|
|
297
|
+
fi
|
|
238
298
|
fi
|
|
239
|
-
if [ -
|
|
240
|
-
|
|
299
|
+
if [ -z "$have_test" ]; then
|
|
300
|
+
if grep -q '"vitest"' "$pkg"; then
|
|
301
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install vitest related --run \\$FILES"
|
|
302
|
+
elif grep -q '"jest"' "$pkg"; then
|
|
303
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install jest --findRelatedTests \\$FILES --passWithNoTests"
|
|
304
|
+
elif grep -q '"test"[[:space:]]*:' "$pkg"; then
|
|
305
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}CI=1 npm run --silent test"
|
|
306
|
+
fi
|
|
241
307
|
fi
|
|
242
308
|
fi
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if [ -
|
|
246
|
-
|
|
309
|
+
|
|
310
|
+
if [ -f "$d\${d:+/}pyproject.toml" ] || [ -f "$d\${d:+/}pytest.ini" ] || [ -f "$d\${d:+/}setup.cfg" ]; then
|
|
311
|
+
if [ -z "$have_tc" ]; then
|
|
312
|
+
if [ -f "$d\${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d\${d:+/}pyproject.toml"; then
|
|
313
|
+
add_gate typecheck "\${pre}py\\$" "\${run}python -m mypy ."
|
|
314
|
+
fi
|
|
315
|
+
fi
|
|
316
|
+
if [ -z "$have_test" ]; then
|
|
317
|
+
add_gate tests "\${pre}py\\$" "\${run}python -m pytest -q"
|
|
247
318
|
fi
|
|
248
319
|
fi
|
|
249
320
|
}
|
|
321
|
+
|
|
322
|
+
detect() {
|
|
323
|
+
# Explicit config wins outright: auto-detection is off for the whole repo.
|
|
324
|
+
if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
|
|
325
|
+
if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
|
|
326
|
+
add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
|
|
327
|
+
fi
|
|
328
|
+
if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
|
|
329
|
+
add_gate tests '.' "$GAUNTLET_TEST"
|
|
330
|
+
fi
|
|
331
|
+
return
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
add_dir_gates ""
|
|
335
|
+
|
|
336
|
+
# Many repos keep no manifest at the root — the code lives in web/, api/,
|
|
337
|
+
# backend/, packages/*. Look two levels down, but only for the kind of gate the
|
|
338
|
+
# root did not already provide, so a repo that works today keeps working.
|
|
339
|
+
ROOT_TC="$TC_GATES"
|
|
340
|
+
ROOT_TEST="$TEST_GATES"
|
|
341
|
+
while IFS= read -r manifest; do
|
|
342
|
+
[ -z "$manifest" ] && continue
|
|
343
|
+
d=$(dirname "$manifest")
|
|
344
|
+
d=\${d#./}
|
|
345
|
+
[ "$d" = "." ] && continue
|
|
346
|
+
add_dir_gates "$d"
|
|
347
|
+
done <<< "$(find . -mindepth 2 -maxdepth 3 \\
|
|
348
|
+
\\( -name package.json -o -name pyproject.toml -o -name pytest.ini \\) \\
|
|
349
|
+
-not -path '*/node_modules/*' -not -path '*/.git/*' \\
|
|
350
|
+
-not -path '*/dist/*' -not -path '*/build/*' \\
|
|
351
|
+
-not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
|
|
352
|
+
}
|
|
250
353
|
detect
|
|
251
|
-
|
|
354
|
+
|
|
355
|
+
# Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
|
|
356
|
+
GATES="$TC_GATES$TEST_GATES"
|
|
357
|
+
if [ -n "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
358
|
+
if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
|
|
359
|
+
quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\\t' ' ' | tr '\\n' '; ')"
|
|
360
|
+
fi
|
|
361
|
+
if [ -z "$GATES" ]; then
|
|
362
|
+
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
363
|
+
quit "skipped: no gates detected for this repo"
|
|
364
|
+
fi
|
|
252
365
|
|
|
253
366
|
# ------------------------------------------------------------------ the gates
|
|
254
|
-
# Cheapest first, stop at the first red. Most failures land in the first gate,
|
|
255
|
-
# so the expensive ones are usually never paid for.
|
|
256
|
-
export FILES=$(printf '%s' "$FILES" | tr '\\n' ' ')
|
|
257
367
|
FAILED=""
|
|
258
368
|
OUTPUT=""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
369
|
+
NO_TEST_MATCH=""
|
|
370
|
+
RAN=""
|
|
371
|
+
# Kept in variables on purpose: a bare \`}\` inside a \${var//pat/repl} replacement
|
|
372
|
+
# closes the expansion early and silently mangles the command.
|
|
373
|
+
FILES_REPL='"\${FILE_ARR[@]}"'
|
|
374
|
+
PAT_BRACE='\${FILES}'
|
|
375
|
+
PAT_PLAIN='$FILES'
|
|
376
|
+
|
|
377
|
+
while IFS=$'\\t' read -r GKIND GEXT GCMD; do
|
|
378
|
+
[ -z "$GKIND" ] && continue
|
|
379
|
+
GFILES=$(printf '%s\\n' "$FILES_NL" | grep -E "$GEXT" || true)
|
|
380
|
+
[ -z "$GFILES" ] && continue
|
|
381
|
+
# $FILES is substituted as a real argument list, not a space-joined string —
|
|
382
|
+
# word splitting would tear "src/my folder/a.ts" into two bogus paths.
|
|
383
|
+
mapfile -t REL_ARR <<< "$GFILES"
|
|
384
|
+
FILE_ARR=()
|
|
385
|
+
for f in "\${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
|
|
386
|
+
export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
|
|
387
|
+
GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
|
|
388
|
+
GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
|
|
389
|
+
RAN="$RAN$GKIND "
|
|
390
|
+
GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
|
|
265
391
|
if [ $? -ne 0 ]; then
|
|
266
|
-
|
|
392
|
+
# A gate whose tool is not installed is a missing gate, not a failing one.
|
|
393
|
+
# Blocking on it would wall off every turn until the user installs something.
|
|
394
|
+
if printf '%s' "$GATE_OUT" | grep -qiE 'command not found|no module named|cannot find module|could not determine executable|canceled due to missing packages|missing script|is not recognized'; then
|
|
395
|
+
SKIP_NOTE="the $GKIND tool is not installed"
|
|
396
|
+
RAN="\${RAN%"$GKIND "}"
|
|
397
|
+
continue
|
|
398
|
+
fi
|
|
399
|
+
FAILED="$GKIND"
|
|
267
400
|
OUTPUT="$GATE_OUT"
|
|
268
|
-
|
|
401
|
+
break
|
|
269
402
|
fi
|
|
270
|
-
|
|
271
|
-
|
|
403
|
+
# A runner handed files it has no tests for exits 0 and looks exactly like a
|
|
404
|
+
# pass. It is the loudest false green: the changed code was never executed.
|
|
405
|
+
if [ "$GKIND" = "tests" ] \\
|
|
406
|
+
&& printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
|
|
407
|
+
NO_TEST_MATCH="$GCMD"
|
|
408
|
+
OUTPUT="$GATE_OUT"
|
|
409
|
+
fi
|
|
410
|
+
done <<< "$GATES"
|
|
411
|
+
|
|
412
|
+
if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "\${GAUNTLET_REQUIRE_TESTS:-}" ]; then
|
|
413
|
+
FAILED="tests"
|
|
414
|
+
OUTPUT="$OUTPUT
|
|
272
415
|
|
|
273
|
-
|
|
416
|
+
The runner matched 0 test files for the changed code, so nothing was executed.
|
|
417
|
+
Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
|
|
418
|
+
fi
|
|
274
419
|
|
|
275
420
|
if [ -z "$FAILED" ]; then
|
|
276
421
|
printf '%s' "$DIFF_HASH" > "$MARKER"
|
|
277
|
-
|
|
422
|
+
if [ -z "$RAN" ]; then
|
|
423
|
+
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
424
|
+
quit "skipped: no gate applied to the changed files"
|
|
425
|
+
elif [ -n "$SKIP_NOTE" ]; then
|
|
426
|
+
quit "green: gates passed ($RAN) — but $SKIP_NOTE"
|
|
427
|
+
elif [ -n "$NO_TEST_MATCH" ]; then
|
|
428
|
+
quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
|
|
429
|
+
fi
|
|
430
|
+
quit "green: gates passed ($RAN)"
|
|
278
431
|
fi
|
|
279
432
|
|
|
280
433
|
# ------------------------------------------------------------------ block red
|
|
@@ -301,7 +454,11 @@ json_escape() {
|
|
|
301
454
|
printf '"%s"' "$s"
|
|
302
455
|
}
|
|
303
456
|
|
|
304
|
-
printf '
|
|
457
|
+
printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
|
|
458
|
+
if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
|
|
459
|
+
# A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
|
|
460
|
+
# valid value and would be ignored, letting the turn end on a red gate.
|
|
461
|
+
printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
|
|
305
462
|
exit 0
|
|
306
463
|
`;
|
|
307
464
|
|
|
@@ -330,7 +487,11 @@ export async function detectStack(targetDir = process.cwd()) {
|
|
|
330
487
|
if (pkg) {
|
|
331
488
|
if (pkg.includes('"vitest"')) test = "vitest";
|
|
332
489
|
else if (pkg.includes('"jest"')) test = "jest";
|
|
333
|
-
|
|
490
|
+
// A delegating monorepo root has no runner as a direct dependency, but its
|
|
491
|
+
// own `test` script still runs the right thing.
|
|
492
|
+
else if (/"test"\s*:/.test(pkg)) test = "npm test";
|
|
493
|
+
if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
|
|
494
|
+
else if (await has("tsconfig.json")) typecheck = "tsc";
|
|
334
495
|
}
|
|
335
496
|
if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
|
|
336
497
|
test = "pytest";
|