@spardutti/claude-skills 2.45.0 → 2.46.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.
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by ship-gate.sh: which changed files a Stryker run can skip, and what its output says.
3
+
4
+ TEST_RE='(\.|_)(test|spec)\.[^.]+$|(^|/)tests?/'
5
+
6
+ stryker_owner() { # stryker_owner <owner> <owned files>
7
+ b=""; [ "$1" != "." ] && b="$1/"
8
+ [ -z "$GAUNTLET_MUTATE" ] && ! grep -q '\.py$' <<< "$2" \
9
+ && grep -qs '@stryker-mutator/core' "${b}package.json"
10
+ }
11
+
12
+ changed_tests() { # changed_tests <family>
13
+ printf '%s\n' "$CHANGED" | grep -E "$TEST_RE" | while IFS= read -r t; do
14
+ [ -n "$t" ] && [ "$(family_of "$t")" = "$1" ] && printf '%s\n' "$t"
15
+ done
16
+ }
17
+
18
+ # Everything a file's verdict rests on besides the file itself and the tests.
19
+ # Tests are left out: adding to them can only kill more, so only a lost line counts.
20
+ files_context() { # files_context <owner> <owned files>
21
+ fam=$(family_of "$1/.")
22
+ { printf '%s\n' "$PROJECT_DIR" "$1" "$(git rev-parse "$BASE" 2>/dev/null)"
23
+ cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
24
+ printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | grep -vE "$TEST_RE" \
25
+ | grep -vxF -f <(printf '%s\n' "$2") | while IFS= read -r f; do
26
+ [ -n "$f" ] || continue
27
+ case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
28
+ done; } | git hash-object --stdin
29
+ }
30
+
31
+ file_receipt() { # file_receipt <context> <file>
32
+ printf '/tmp/claude-shipgate-file-%s\n' "$({ printf '%s\n' "$1" "$2"; cat "$2"; } | git hash-object --stdin)"
33
+ }
34
+
35
+ test_then() { # test_then <snapshot> <test>: its content when the snapshot was taken
36
+ if grep -qxF "$2" "$1/manifest"; then cat "$1/files/$2" 2>/dev/null
37
+ else git show "$BASE:$2" 2>/dev/null; fi
38
+ }
39
+
40
+ # Never `| grep -q` under pipefail: diff exits 1 on any difference, and an early
41
+ # grep exit fails the writer, so a match reads as none.
42
+ tests_lost_lines() { # tests_lost_lines <snapshot> <family>
43
+ [ -f "$1/manifest" ] || return 0
44
+ while IFS= read -r t; do
45
+ [ -n "$t" ] || continue
46
+ case "$(diff <(test_then "$1" "$t") <(cat "$t" 2>/dev/null))" in *$'\n<'*) return 0 ;; esac
47
+ done <<< "$({ cat "$1/manifest"; changed_tests "$2"; } | sort -u)"
48
+ return 1
49
+ }
50
+
51
+ # Sets OWNED to the files that still need mutating.
52
+ files_split() { # files_split <context> <owner> <label>
53
+ fam=$(family_of "$2/."); kept=""; rest=""
54
+ while IFS= read -r f; do
55
+ [ -n "$f" ] || continue
56
+ r=$(file_receipt "$1" "$f")
57
+ if [ -f "$r" ] && ! tests_lost_lines "$(cat "$r")" "$fam"; then kept="$kept ${f#"$2"/}"
58
+ else rest="$rest$f"$'\n'; fi
59
+ done <<< "$OWNED"
60
+ OWNED=${rest%$'\n'}
61
+ [ -n "$kept" ] || return 0
62
+ echo " $3 stryker — ok, not re-run: passed and unchanged since:$kept"
63
+ echo " A change in a file they call is not re-checked."
64
+ b=""; [ "$2" != "." ] && b="$2/"
65
+ [ -z "$OWNED" ] && stryker_page_tests "$b" "$3"
66
+ return 0
67
+ }
68
+
69
+ # A finding with no location cannot be pinned on one file, so it keeps none.
70
+ files_record() { # files_record <context> <owner> <findings>
71
+ [ -n "$(printf '%s\n' "$3" | grep . | grep -vE ':[0-9]+:[0-9]+$')" ] && return 0
72
+ fam=$(family_of "$2/.")
73
+ snap="/tmp/claude-shipgate-tests-$({ printf '%s\n' "$1"; changed_tests "$fam" \
74
+ | while IFS= read -r t; do printf '%s\n' "$t"; cat "$t" 2>/dev/null; done; } | git hash-object --stdin)"
75
+ mkdir -p "$snap/files"; changed_tests "$fam" > "$snap/manifest"
76
+ while IFS= read -r t; do
77
+ [ -f "$t" ] && mkdir -p "$snap/files/$(dirname "$t")" && cp "$t" "$snap/files/$t"
78
+ done < "$snap/manifest"
79
+ while IFS= read -r f; do
80
+ [ -n "$f" ] || continue
81
+ grep -qF " ${f#"$2"/}:" <<< "$3" && continue
82
+ printf '%s\n' "$snap" > "$(file_receipt "$1" "$f")"
83
+ done <<< "$OWNED"
84
+ }
85
+
86
+ # Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
87
+ # HEADER every run and repeats the word in its table, so a bare grep reports
88
+ # survivors on a clean run — worse than not running at all. Stryker marks each
89
+ # real finding `[Survived]`; table and border lines are excluded outright.
90
+ # NoCoverage is printed exactly like Survived and means something worse: not
91
+ # "a test missed this mutation" but "no test executes this line at all". The
92
+ # gate matched only [Survived], so a diff whose files have no tests came back
93
+ # PASS — 306 uncovered mutants reported as proven on one commit. It is judged
94
+ # only for files that went in WITH a range: a glob-shaped path is mutated
95
+ # whole, so its untouched lines would report uncovered forever, and a finding
96
+ # nobody can close is what teaches people to --force.
97
+ #
98
+ # The status line carries no file name; the line under it does, as
99
+ # file:line:column. They are paired here rather than grepped separately.
100
+ stryker_findings() { # stryker_findings <output> <globbed paths, comma-wrapped>
101
+ printf '%s\n' "$1" | awk -v globbed="$2" '
102
+ /^\[Survived\]/ { pend = $0; kind = "s"; next }
103
+ /^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
104
+ pend != "" {
105
+ file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
106
+ if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
107
+ pend = ""
108
+ }
109
+ # A survivor with no location under it is still a survivor, never dropped.
110
+ END { if (pend != "" && kind == "s") print pend }
111
+ '
112
+ }
@@ -25,6 +25,18 @@ py_mutmut() {
25
25
  command -v mutmut >/dev/null 2>&1 && printf 'mutmut\n'
26
26
  }
27
27
 
28
+ # Node that lives only in Docker leaves no Stryker on the host. An executable
29
+ # .stryker-run runs it there instead, taking the arguments stryker would.
30
+ stryker_bin() { if [ -x "$1.stryker-run" ]; then echo ./.stryker-run; else echo npx --no-install stryker; fi; }
31
+ stryker_elsewhere() { # stryker_elsewhere <tool> <base>: after a failed run, say where Stryker could run
32
+ [ "$1" = stryker ] && [ ! -x "$2.stryker-run" ] || return 0
33
+ d=$(cd "${2:-.}" && pwd)
34
+ while [ "$d" != / ]; do [ -d "$d/node_modules/@stryker-mutator/core" ] && return 0; d=$(dirname "$d"); done
35
+ echo " Stryker is not installed on this machine. If Node runs only in Docker, add an"
36
+ echo " executable $2.stryker-run that runs it there and passes on its arguments:"
37
+ echo " docker compose run --rm -T <service> nice -n 19 npx stryker \"\$@\""
38
+ }
39
+
28
40
  owner_of() {
29
41
  d=$(dirname "$1")
30
42
  while :; do
@@ -127,7 +139,7 @@ project_key() { # project_key <owner> <tool>
127
139
  fam=$(family_of "$1/.")
128
140
  { printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
129
141
  "$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
130
- cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh"
142
+ cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
131
143
  printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
132
144
  [ -n "$f" ] || continue
133
145
  case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
@@ -46,7 +46,7 @@
46
46
  set -uo pipefail
47
47
 
48
48
  HERE=$(cd "$(dirname "$0")" && pwd)
49
- . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
49
+ . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
50
50
 
51
51
  MODE=""
52
52
  case "${1:-}" in
@@ -122,7 +122,7 @@ if [ "$MODE" = force ]; then
122
122
  fi
123
123
  gate_report "$RECEIPT_KEY"
124
124
  SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
125
- | grep -vE '(\.|_)(test|spec)\.[^.]+$|(^|/)tests?/|(^|/)\.stryker-tmp/' | while read -r f; do
125
+ | grep -vE "$TEST_RE|(^|/)\.stryker-tmp/" | while read -r f; do
126
126
  [ -f "$f" ] && printf '%s\n' "$f"
127
127
  done)
128
128
 
@@ -223,6 +223,7 @@ for owner in $OWNERS; do
223
223
  OWNED=$(while IFS= read -r f; do
224
224
  [ "$(owner_of "$f")" = "$owner" ] && printf '%s\n' "$f"
225
225
  done <<< "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" || true)
226
+ FCTX=""; if stryker_owner "$owner" "$OWNED"; then FCTX=$(files_context "$owner" "$OWNED"); files_split "$FCTX" "$owner" "$label"; fi
226
227
  [ -z "$OWNED" ] && continue
227
228
 
228
229
  # ONE comma-joined --mutate value, never a flag per range. Stryker's CLI
@@ -271,7 +272,7 @@ for owner in $OWNERS; do
271
272
  # 8 of them on one commit, all long since killed. --mutate already scopes
272
273
  # the run to the changed hunks, so incremental buys nothing here and costs
273
274
  # a cache that goes stale exactly the way mutmut's did.
274
- CMD="${RUN}npx --no-install stryker run --concurrency $(gate_workers) --mutate '${FLAGS#,}'"; stryker_page_tests "$base" "$label"
275
+ CMD="${RUN}$(stryker_bin "$base") run --concurrency $(gate_workers) --mutate '${FLAGS#,}'"; stryker_page_tests "$base" "$label"
275
276
  elif [ -z "$PY" ] && [ -f "$base"package.json ]; then
276
277
  MISSING="$MISSING $label needs Stryker:
277
278
  npm --prefix ${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
@@ -324,32 +325,9 @@ for owner in $OWNERS; do
324
325
  T0=$SECONDS; OUT=$(export FILES="$(printf '%s' "$OWNED" | tr '\n' ' ')" MUTATE_FLAGS="--mutate ${FLAGS#,}"; eval "$CMD" 2>&1); RC=$?; T=$((SECONDS - T0))
325
326
  [ "$TOOL" = mutmut ] && [ $RC -eq 0 ] && { printf '%s\n' "$OUT" > "$PKEY.out"; cp "$MLOG" "$PKEY.log" 2>/dev/null; }
326
327
  [ -n "$SCOPE_RE" ] && [ $RC -eq 0 ] && OUT=$(printf '%s\n' "$OUT" | grep -E "^[[:space:]]*($SCOPE_RE)\.x")
327
- # Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
328
- # HEADER every run and repeats the word in its table, so a bare grep reports
329
- # survivors on a clean run — worse than not running at all. Stryker marks each
330
- # real finding `[Survived]`; table and border lines are excluded outright.
331
- # NoCoverage is printed exactly like Survived and means something worse: not
332
- # "a test missed this mutation" but "no test executes this line at all". The
333
- # gate matched only [Survived], so a diff whose files have no tests came back
334
- # PASS — 306 uncovered mutants reported as proven on one commit. It is judged
335
- # only for files that went in WITH a range: a glob-shaped path is mutated
336
- # whole, so its untouched lines would report uncovered forever, and a finding
337
- # nobody can close is what teaches people to --force.
338
- #
339
- # The status line carries no file name; the line under it does, as
340
- # file:line:column. They are paired here rather than grepped separately.
341
328
  if [ "$TOOL" = stryker ]; then
342
- ALL=$(printf '%s\n' "$OUT" | awk -v globbed="$GLOBBED" '
343
- /^\[Survived\]/ { pend = $0; kind = "s"; next }
344
- /^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
345
- pend != "" {
346
- file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
347
- if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
348
- pend = ""
349
- }
350
- # A survivor with no location under it is still a survivor, never dropped.
351
- END { if (pend != "" && kind == "s") print pend }
352
- ')
329
+ ALL=$(stryker_findings "$OUT" "$GLOBBED")
330
+ [ -n "$FCTX" ] && [ $RC -eq 0 ] && files_record "$FCTX" "$owner" "$ALL"
353
331
  else
354
332
  ALL=$(printf '%s\n' "$OUT" | grep -E '[Ss]urvived' \
355
333
  | grep -vE '^[[:space:]]*[#│|+-]|# *surviv')
@@ -433,7 +411,7 @@ for owner in $OWNERS; do
433
411
  STATUS=1
434
412
  elif [ $RC -ne 0 ]; then
435
413
  echo " $label $TOOL (${T}s) — the run failed:"
436
- printf '%s\n' "$OUT" | tail -12 | sed 's/^/ /'
414
+ printf '%s\n' "$OUT" | tail -12 | sed 's/^/ /'; stryker_elsewhere "$TOOL" "$base"
437
415
  [ "$STATUS" = 0 ] && STATUS=2
438
416
  elif [ -n "$NOTE" ]; then
439
417
  printf '%s\n' "$NOTE"
@@ -28,6 +28,7 @@ const GAUNTLET_FILENAME = "gauntlet.sh";
28
28
  const SHIP_GATE_FILENAME = "ship-gate.sh";
29
29
  const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
30
30
  const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
31
+ const SHIP_GATE_FILES_FILENAME = "ship-gate-files.sh";
31
32
  const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
32
33
  const VERSION_CHECK_FILENAME = "version-check.sh";
33
34
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
@@ -108,6 +109,7 @@ export async function setupHook(targetDir = process.cwd()) {
108
109
  SHIP_GATE_FILENAME,
109
110
  SHIP_GATE_PROJECTS_FILENAME,
110
111
  SHIP_GATE_STRUCTURE_FILENAME,
112
+ SHIP_GATE_FILES_FILENAME,
111
113
  SHIP_GATE_HOOK_FILENAME,
112
114
  VERSION_CHECK_FILENAME,
113
115
  ]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.45.0",
3
+ "version": "2.46.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-projects.sh ../scripts/ship-gate-structure.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-structure.sh ../scripts/ship-gate-files.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": {