@spardutti/claude-skills 2.32.3 → 2.33.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 CHANGED
@@ -122,7 +122,7 @@ Cross-cutting craft — applies to any stack, any language.
122
122
  | `git-merge-conflicts` | Resolve a merge/rebase conflict by intent, not by side — `zdiff3` to see the merge base, the inverted meaning of `ours`/`theirs` during a rebase, the semantic and tree conflicts git never marks, lockfiles and migrations you regenerate instead of merging |
123
123
  | `research` | Open the source, don't recall it — route to the tool that owns the answer, primary sources only, pin the version you're actually on, run the validator instead of guessing, and mark every claim VERIFIED / INFERRED / UNVERIFIED |
124
124
  | `typescript-best-practices` | TypeScript 7.x — type design, generics, type guards, `satisfies`, `using`, error handling, `tsconfig` |
125
- | `testing-best-practices` 📦 | Arrange-Act-Assert, factory-based test data, isolation, mocking boundaries, a pyramid-balanced suite; bundle covers mutation testing — Stryker and mutmut setup, `ignoreStatic`, diff-scoped runs, the traps, and working score thresholds |
125
+ | `testing-best-practices` 📦 | Arrange-Act-Assert, assertions that fail when the code breaks — exact values, list ties and short pages, side effects, both sides of a limit — factory-based test data, isolation, mocking boundaries, a pyramid-balanced suite; bundle covers mutation testing — Stryker and mutmut setup, `ignoreStatic`, diff-scoped runs, the traps, and working score thresholds |
126
126
  | `security-practices` | OWASP Top 10 prevention, input validation, auth, SQL injection, XSS, CSRF, secure defaults |
127
127
 
128
128
  ## Commands
@@ -133,7 +133,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
133
133
  |---------|--------------|
134
134
  | `/ship` | Unified delivery pipeline — commit → gate → PR → merge → release. The gate is the one enforcement moment: it checks the diff's file lengths, audits it against the skills installed in the project, and mutation-tests the changed lines to prove the tests would catch a break — **fixing what it finds** rather than handing you a list. It runs before the **PR**, not before every commit: its scope is the whole branch, so gating each commit re-mutated every file the branch had ever touched. `--force` skips it. No argument steps through interactively; `/ship pr` runs through PR creation; `/ship release` runs the full pipeline |
135
135
  | `/discover` | Find the right problem before deciding what to build — diverges first: generates competing framings through blind subagents under forced constraints, stress-tests the winner with a blind critic, and refuses to converge until every open question is answered or deferred, then drafts scope, non-goals, edge cases and success criteria for you to correct. Run before `/plan-feature` |
136
- | `/plan-feature` | Integration-first feature planning, then building — 3 parallel subagents scan for reusable code, patterns and touch points, and the plan lands with a contract and a per-worker file split; on your go it dispatches parallel implementation workers that each load their own skills and report back as one diff |
136
+ | `/plan-feature` | Integration-first feature planning, then building — 3 parallel subagents scan for reusable code, patterns and touch points, and the plan lands with a contract and a per-worker file split; on your go it dispatches parallel implementation workers that each load their own skills, then mutation-tests their work once and sends each test gap back to the worker that wrote it, and reports one diff |
137
137
  | `/refactor` | Detect size / complexity / duplication / coupling issues via 4 parallel subagents, then refactor |
138
138
  | `/deep-review` | Multi-agent deep code review — 5 parallel subagents catch guard bypasses, lost async state, wrong-table queries, dead references, protocol violations |
139
139
  | `/test-review` | Write-then-verify test review — scopes to the diff, runs red-green + mutation gates, then an isolated read-only subagent proves each test would catch a regression instead of rubber-stamping it |
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by ship-gate.sh: which project owns a file, how it runs mutmut, and
3
+ # whether a project's last clean verdict still stands.
4
+
5
+ # mutmut is installed into the project's environment, not onto PATH. Ask the
6
+ # project how to run its own tools before falling back to a bare binary.
7
+ py_mutmut() {
8
+ d="$1"
9
+ # Returned relative to the PROJECT, because the command runs from inside it.
10
+ # Tests that need a database, a queue, or any other service cannot run on the
11
+ # host: mutmut still generates every mutant and reports them all "not
12
+ # checked", which prints exactly like a clean run. An executable .mutmut-run
13
+ # in the project runs mutmut wherever those services are — typically
14
+ # `docker compose run` against the app container — and takes precedence over
15
+ # every host-local option below.
16
+ if [ -x "$d.mutmut-run" ]; then printf './.mutmut-run\n'; return; fi
17
+ if [ -x "$d.venv/bin/mutmut" ]; then printf './.venv/bin/mutmut\n'; return; fi
18
+ if [ -f "$d""uv.lock" ] && command -v uv >/dev/null 2>&1; then printf 'uv run mutmut\n'; return; fi
19
+ if [ -f "$d""poetry.lock" ] && command -v poetry >/dev/null 2>&1; then printf 'poetry run mutmut\n'; return; fi
20
+ command -v mutmut >/dev/null 2>&1 && printf 'mutmut\n'
21
+ }
22
+
23
+ owner_of() {
24
+ d=$(dirname "$1")
25
+ while :; do
26
+ if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \
27
+ || [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
28
+ printf '%s\n' "${d#./}"; return
29
+ fi
30
+ [ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\n'; return; }
31
+ d=$(dirname "$d")
32
+ done
33
+ }
34
+
35
+ # mutmut records every mutant "not checked" when it stops before testing one.
36
+ # Blaming an unreachable database for that sent one agent chasing Docker for 20 minutes.
37
+ mutmut_unchecked() { # mutmut_unchecked <label> <base> <count> <run log>
38
+ echo " $1 mutmut — UNPROVEN: $3 mutant(s) were never run."
39
+ stop='Failed to run clean test|failed to collect stats|Failed to collect list of tests|Stopping early'
40
+ if grep -aqsE "$stop" "$4"; then
41
+ echo " mutmut stopped before testing a single mutant:"
42
+ grep -aE "^(FAILED|ERROR) |^[A-Za-z]*Error: |$stop" "$4" | tail -8 | sed 's/^/ /'
43
+ echo " It runs the whole suite, then these tests again in one process, so a"
44
+ echo " test that passes alone can fail here on state an earlier test left."
45
+ return
46
+ fi
47
+ echo " They are recorded \"not checked\", so the suite proved nothing"
48
+ echo " about them. Usually the tests need a service this run cannot"
49
+ echo " reach — a database, a queue. Put an executable .mutmut-run in"
50
+ echo " ${2}that runs mutmut where those services are, typically"
51
+ echo " docker compose run against the app container; the gate uses"
52
+ echo " it ahead of every host-local option."
53
+ }
54
+
55
+ family_of() { # js, py, or any when no manifest says
56
+ o=$(owner_of "$1")
57
+ if [ -f "$o/package.json" ]; then echo js
58
+ elif [ -f "$o/pyproject.toml" ] || [ -f "$o/pytest.ini" ] || [ -f "$o/setup.cfg" ]; then echo py
59
+ else echo any
60
+ fi
61
+ }
62
+
63
+ # A project's verdict can only move when the gate or a changed file of its own
64
+ # kind moves, so fixing the API's tests no longer re-runs Stryker on two JS apps.
65
+ project_key() { # project_key <owner> <tool>
66
+ fam=$(family_of "$1/.")
67
+ { printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
68
+ "$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
69
+ cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh"
70
+ printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
71
+ [ -n "$f" ] || continue
72
+ case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
73
+ done; } | git hash-object --stdin
74
+ }
@@ -45,6 +45,9 @@
45
45
 
46
46
  set -uo pipefail
47
47
 
48
+ HERE=$(cd "$(dirname "$0")" && pwd)
49
+ . "$HERE/ship-gate-projects.sh" || { echo "ship-gate: cannot read $HERE/ship-gate-projects.sh"; exit 1; }
50
+
48
51
  MODE=""
49
52
  case "${1:-}" in
50
53
  --key) MODE=key; shift ;;
@@ -218,36 +221,6 @@ fi
218
221
  # ---------------------------------------------------------- which project owns
219
222
  # A monorepo holds several projects, each with its own runner and its own
220
223
  # mutation tool. Walk up from each changed file to the nearest manifest.
221
- # mutmut is installed into the project's environment, not onto PATH. Ask the
222
- # project how to run its own tools before falling back to a bare binary.
223
- py_mutmut() {
224
- d="$1"
225
- # Returned relative to the PROJECT, because the command runs from inside it.
226
- # Tests that need a database, a queue, or any other service cannot run on the
227
- # host: mutmut still generates every mutant and reports them all "not
228
- # checked", which prints exactly like a clean run. An executable .mutmut-run
229
- # in the project runs mutmut wherever those services are — typically
230
- # `docker compose run` against the app container — and takes precedence over
231
- # every host-local option below.
232
- if [ -x "$d.mutmut-run" ]; then printf './.mutmut-run\n'; return; fi
233
- if [ -x "$d.venv/bin/mutmut" ]; then printf './.venv/bin/mutmut\n'; return; fi
234
- if [ -f "$d""uv.lock" ] && command -v uv >/dev/null 2>&1; then printf 'uv run mutmut\n'; return; fi
235
- if [ -f "$d""poetry.lock" ] && command -v poetry >/dev/null 2>&1; then printf 'poetry run mutmut\n'; return; fi
236
- command -v mutmut >/dev/null 2>&1 && printf 'mutmut\n'
237
- }
238
-
239
- owner_of() {
240
- d=$(dirname "$1")
241
- while :; do
242
- if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \
243
- || [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
244
- printf '%s\n' "${d#./}"; return
245
- fi
246
- [ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\n'; return; }
247
- d=$(dirname "$d")
248
- done
249
- }
250
-
251
224
  OWNERS=$(while IFS= read -r f; do owner_of "$f"; done <<< "$FILES" | sort -u)
252
225
 
253
226
  # --------------------------------------------- check 2: mutation, per project
@@ -291,7 +264,7 @@ for owner in $OWNERS; do
291
264
  esac
292
265
  done <<< "$OWNED"
293
266
 
294
- base=""; RUN=""
267
+ base=""; RUN=""; SCOPE_RE=""; GLOBS=""; MODS=""; MLOG="${TMPDIR:-/tmp}/ship-gate-mutmut.$$"
295
268
  if [ "$owner" != "." ]; then base="$owner/"; RUN="cd '$owner' && "; fi
296
269
  if [ -n "$GAUNTLET_MUTATE" ]; then
297
270
  CMD="$GAUNTLET_MUTATE"; TOOL="config"
@@ -318,15 +291,18 @@ for owner in $OWNERS; do
318
291
  continue
319
292
  elif [ -n "$(py_mutmut "$base")" ]; then
320
293
  TOOL="mutmut"
321
- # mutmut takes its scope from [tool.mutmut] in pyproject, not from a flag:
322
- # --paths-to-mutate was 2.x, and 3.x filters by fnmatch globs over mutant
323
- # NAMES (app.balance.reserve.*). There is no per-line scoping at all.
294
+ # 3.x has no per-line scoping, only fnmatch globs over mutant NAMES, so a
295
+ # compare run is scoped to the changed modules. A baseline is recorded whole.
324
296
  #
325
297
  # `mutmut run` prints 🙁 for a survivor and exits 0 either way — parsing it
326
298
  # reports clean with survivors sitting there, which is a false green and
327
299
  # worse than reporting nothing. `mutmut results` is the readable source:
328
300
  # it prints "<mutant name>: survived" per survivor.
329
301
  M="$(py_mutmut "$base")"
302
+ [ "$MODE" != baseline ] && [ -f "$base.mutmut-baseline" ] && MODS=$(printf '%s\n' "$OWNED" \
303
+ | sed -n "s#^$base\(.*\)\.py\$#\1#p" | tr / . | sed 's#^src\.##' | sort -u)
304
+ [ -n "$MODS" ] && GLOBS=$(printf " '%s.x*'" $MODS) \
305
+ && SCOPE_RE=$(printf '%s\n' "$MODS" | sed 's/\./\\./g' | paste -sd'|' -)
330
306
  # mutmut caches verdicts in ./mutants and only invalidates one when the
331
307
  # SOURCE of that function changes. Adding a test does not, so it replays the
332
308
  # old "survived" and calls mutants the new tests kill still alive — measured
@@ -335,7 +311,7 @@ for owner in $OWNERS; do
335
311
  # makes both describe the tree being shipped; `mutmut run` rewrites it
336
312
  # anyway, and a cold run is ~35s for ~1000 mutants.
337
313
  # One cd for all three: they run in the same shell, already there.
338
- CMD="${RUN}rm -rf mutants; $M run >/dev/null 2>&1; $M results"
314
+ CMD="${RUN}rm -rf mutants; $M run$GLOBS >'$MLOG' 2>&1; $M results"
339
315
  else
340
316
  MISSING="$MISSING $label needs mutmut:
341
317
  uv add --dev mutmut # or: poetry add --group dev mutmut, pip install mutmut
@@ -348,7 +324,14 @@ for owner in $OWNERS; do
348
324
  continue
349
325
  fi
350
326
 
327
+ # Only a clean run is recorded, and a baseline run always asks mutmut afresh.
328
+ PKEY=""; [ "$TOOL" != config ] && PKEY="/tmp/claude-shipgate-project-$(project_key "$owner" "$TOOL")"
329
+ if [ -n "$PKEY" ] && [ -f "$PKEY" ] && { [ "$TOOL" != mutmut ] || [ "$MODE" != baseline ]; }; then
330
+ echo " $label $TOOL — ok, not re-run: nothing of its kind changed since it passed"
331
+ continue
332
+ fi
351
333
  OUT=$(eval "$CMD" 2>&1); RC=$?
334
+ [ -n "$SCOPE_RE" ] && [ $RC -eq 0 ] && OUT=$(printf '%s\n' "$OUT" | grep -E "^[[:space:]]*($SCOPE_RE)\.x")
352
335
  # Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
353
336
  # HEADER every run and repeats the word in its table, so a bare grep reports
354
337
  # survivors on a clean run — worse than not running at all. Stryker marks each
@@ -389,19 +372,15 @@ for owner in $OWNERS; do
389
372
  FIXED=0
390
373
  NOTE=""
391
374
 
392
- # Stryker takes --mutate per changed hunk, so it already answers "did YOUR
393
- # change get tested". mutmut cannot be asked that — 3.x dropped 1.x's
394
- # --use-patch-file and never replaced it — so it mutates all of source_paths
395
- # and reports the repo's entire backlog. Held to the same bar, the Python half
396
- # answers "is this repo perfect" instead, which it never is, so it could never
397
- # go green. The baseline supplies the missing half: survivors recorded once
398
- # are accepted debt, and only a name that is not in it fails the gate.
399
- #
400
- # A mutant name carries an index within its own function, not a line number,
401
- # so editing elsewhere in the file does not rename it — and editing the
402
- # function itself DOES, which correctly re-charges its survivors to that edit.
375
+ # Module scope still returns a changed file's old survivors; the baseline accepts
376
+ # them. Names index within a function, so only editing that function re-charges them.
403
377
  if [ "$TOOL" = mutmut ]; then
404
378
  BL="$base.mutmut-baseline"
379
+ # A glob that matches no mutant aborts the run, so nothing in scope was tested.
380
+ if [ -n "$SCOPE_RE" ] && grep -qs "nothing matches" "$MLOG"; then
381
+ echo " $label mutmut — UNPROVEN: no mutant in the changed module(s): $(printf '%s ' $MODS)"
382
+ [ "$STATUS" = 0 ] && STATUS=2; rm -f "$MLOG"; continue
383
+ fi
405
384
  NOWF=$(mktemp)
406
385
  printf '%s\n' "$OUT" \
407
386
  | sed -n 's/^[[:space:]]*\([^[:space:]]*\): survived$/\1/p' | sort -u > "$NOWF"
@@ -419,15 +398,9 @@ for owner in $OWNERS; do
419
398
  # meaning the mutant was never executed at all.
420
399
  NOTCHECKED=$(printf '%s\n' "$OUT" | grep -c ': not checked$')
421
400
  if [ "$NOTCHECKED" -gt 0 ]; then
422
- echo " $label mutmut — UNPROVEN: $NOTCHECKED mutant(s) were never run."
423
- echo " They are recorded \"not checked\", so the suite proved nothing"
424
- echo " about them. Usually the tests need a service this run cannot"
425
- echo " reach — a database, a queue. Put an executable .mutmut-run in"
426
- echo " ${base}that runs mutmut where those services are, typically"
427
- echo " docker compose run against the app container; the gate uses"
428
- echo " it ahead of every host-local option."
401
+ mutmut_unchecked "$label" "$base" "$NOTCHECKED" "$MLOG"
429
402
  [ "$STATUS" = 0 ] && STATUS=2
430
- rm -f "$NOWF"
403
+ rm -f "$NOWF" "$MLOG"
431
404
  continue
432
405
  fi
433
406
 
@@ -439,7 +412,7 @@ for owner in $OWNERS; do
439
412
  # -Fxv against an empty baseline reports every survivor, which is right:
440
413
  # an empty baseline means nothing has been accepted.
441
414
  SURVIVED=$(grep -Fxv -f "$BL" "$NOWF" | head -20)
442
- FIXED=$(grep -Fxvc -f "$NOWF" "$BL")
415
+ FIXED=$(grep -E "^(${SCOPE_RE:-.*})\.x" "$BL" | grep -Fxvc -f "$NOWF")
443
416
  else
444
417
  cp "$NOWF" "$BL"
445
418
  NOTE=" $label mutmut — no baseline, so nothing could be compared. Recorded
@@ -448,7 +421,7 @@ for owner in $OWNERS; do
448
421
  SURVIVED=""
449
422
  [ "$STATUS" = 0 ] && STATUS=2
450
423
  fi
451
- rm -f "$NOWF"
424
+ rm -f "$NOWF" "$MLOG"
452
425
  fi
453
426
 
454
427
  if [ -n "$SURVIVED" ]; then
@@ -473,6 +446,7 @@ for owner in $OWNERS; do
473
446
  printf '%s\n' "$NOTE"
474
447
  else
475
448
  echo " $label $TOOL — ok, nothing survived and nothing was uncovered"
449
+ [ -n "$PKEY" ] && touch "$PKEY"
476
450
  [ "$FIXED" -gt 0 ] && echo " ($FIXED baselined survivor(s) now killed — --baseline banks them)"
477
451
  fi
478
452
  done
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
17
17
  // explicitly applied before the first edit
18
18
  // gauntlet.sh Stop — fast typecheck and tests on the diff
19
19
  // ship-gate.sh file length and mutation, behind an exit code
20
+ // ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
20
21
  // ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
21
22
  // version-check.sh SessionStart — a line when a newer catalog exists
22
23
 
@@ -25,6 +26,7 @@ const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
25
26
  const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
26
27
  const GAUNTLET_FILENAME = "gauntlet.sh";
27
28
  const SHIP_GATE_FILENAME = "ship-gate.sh";
29
+ const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
28
30
  const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
29
31
  const VERSION_CHECK_FILENAME = "version-check.sh";
30
32
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
@@ -103,6 +105,7 @@ export async function setupHook(targetDir = process.cwd()) {
103
105
  APPLICATION_GATE_FILENAME,
104
106
  GAUNTLET_FILENAME,
105
107
  SHIP_GATE_FILENAME,
108
+ SHIP_GATE_PROJECTS_FILENAME,
106
109
  SHIP_GATE_HOOK_FILENAME,
107
110
  VERSION_CHECK_FILENAME,
108
111
  ]) {
@@ -124,7 +127,7 @@ export async function setupHook(targetDir = process.cwd()) {
124
127
  console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
125
128
  console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
126
129
  console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
127
- console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
130
+ console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship and /plan-feature)`);
128
131
  console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
129
132
  console.log(` Settings updated: .claude/settings.json`);
130
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.32.3",
3
+ "version": "2.33.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "claude-skills": "bin/cli.mjs"
12
12
  },
13
13
  "scripts": {
14
- "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh hooks/",
14
+ "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh hooks/",
15
15
  "postpack": "git checkout -- README.md && rm -rf hooks"
16
16
  },
17
17
  "engines": {