@spardutti/claude-skills 2.51.2 → 2.52.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 +7 -3
- package/hooks/edit-check.sh +39 -0
- package/hooks/gate-config.sh +37 -0
- package/hooks/line-limit.sh +125 -0
- package/hooks/lint-ratchet.mjs +158 -0
- package/hooks/ratchet-baseline.mjs +39 -0
- package/hooks/ship-gate-files.sh +20 -1
- package/hooks/ship-gate-projects.sh +1 -1
- package/hooks/ship-gate-structure.sh +16 -2
- package/hooks/ship-gate.sh +4 -29
- package/hooks/skill-flags-record.sh +6 -4
- package/hooks/text-rules.mjs +46 -0
- package/lib/hook-settings.mjs +7 -0
- package/lib/scaffold-stryker.mjs +7 -2
- package/lib/setup-hook.mjs +33 -1
- package/lib/stryker-ts7.mjs +26 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ npx @spardutti/claude-skills
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
Claude Skills Installer v2.
|
|
39
|
+
Claude Skills Installer v2.52.0
|
|
40
40
|
|
|
41
41
|
── Frontend ──────────────────────────────
|
|
42
42
|
◉ react ◯ tanstack-query
|
|
@@ -76,7 +76,8 @@ install adds the part that does not negotiate:
|
|
|
76
76
|
- A `Stop` hook that runs your typecheck and tests on the changed files every turn,
|
|
77
77
|
and refuses to let the turn end red.
|
|
78
78
|
- A delivery gate that **changes your code on purpose and checks whether any test
|
|
79
|
-
notices.** When none do, the PR does not open.
|
|
79
|
+
notices.** When none do, the PR does not open. The same gate runs one
|
|
80
|
+
`/code-review` round for logic bugs and fixes what it finds.
|
|
80
81
|
|
|
81
82
|
That last one is the difference. Your agent says the tests pass. This finds out.
|
|
82
83
|
|
|
@@ -137,6 +138,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
137
138
|
| `/optimize` | Make one measured job faster without changing what it proves — fixes a benchmark and a result that must not move, sends 4 read-only subagents to find suspects, then changes one thing per round with a prediction written first, keeping only what beats the noise. Profiles after every wrong prediction, reruns before judging a result that flipped, and ends by writing the cause as a skill rule for you |
|
|
138
139
|
| `/handoff` | Write a handoff doc so a fresh session picks the work up — captures what auto-compaction loses: decisions made and rejected, dead ends already ruled out, why the tree is dirty, and the one next step |
|
|
139
140
|
| `/lockdown` | Per-repo supply-chain hardening — detects the package managers, Dockerfiles, and CI in use, then guides you through and applies install-time, deploy, and pipeline hardening for npm/pnpm, pip/uv, Docker, and GitHub Actions |
|
|
141
|
+
| `/add-rule` | Turn a mistake Claude keeps making into a check on every edit — a text rule with a bad and a good example from real code when a pattern can catch it, or a lesson shown only on the files it names when it needs judgment. Old hits go into a baseline and come back as "fix it if you can" notes; nothing is added to CLAUDE.md |
|
|
140
142
|
| `/skill-report` | Which installed skills earn their place in this repo — every `/ship` skills audit is logged by a hook, and this tallies it: how often each skill applied, how often it caught a violation, and the rule it catches most. Names rules that break often enough to become a script check, and skills that never flag and may be dead weight |
|
|
141
143
|
|
|
142
144
|
## How It Works
|
|
@@ -194,7 +196,9 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
|
|
|
194
196
|
ship-gate.sh — /ship's file-length, folder-structure and mutation checks, behind an exit code
|
|
195
197
|
ship-gate-hook.sh — refuses gh pr create/merge without a ship-gate receipt
|
|
196
198
|
version-check.sh — SessionStart nudge when a newer catalog is published
|
|
197
|
-
skill-flags-record.sh — logs each /ship skills
|
|
199
|
+
skill-flags-record.sh — logs each /ship skills audit and /code-review; skill-report.mjs tallies them
|
|
200
|
+
edit-check.sh — PostToolUse check on each edited file: line-limit.sh, lint-ratchet.mjs, .edit-rules.json
|
|
201
|
+
line-limit.sh — the 200-line ratchet, shared by the edit check, the ship gate and preflight
|
|
198
202
|
gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
|
|
199
203
|
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
200
204
|
preflight.sh — everything that must pass before a release
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
INPUT=$(cat)
|
|
4
|
+
|
|
5
|
+
FILE=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
6
|
+
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
7
|
+
[ -z "$FILE" ] && exit 0
|
|
8
|
+
|
|
9
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
11
|
+
if [ -f "$HERE/gate-config.sh" ]; then
|
|
12
|
+
. "$HERE/gate-config.sh"
|
|
13
|
+
load_gauntlet_conf "$PROJECT_DIR/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
|
|
14
|
+
is_ignored "${FILE#"$PROJECT_DIR"/}" && exit 0
|
|
15
|
+
fi
|
|
16
|
+
FAILS=""
|
|
17
|
+
NOTES=""
|
|
18
|
+
|
|
19
|
+
run() {
|
|
20
|
+
local out rc
|
|
21
|
+
out=$("$@" 2>&1); rc=$?
|
|
22
|
+
[ -z "$out" ] && return
|
|
23
|
+
if [ "$rc" = 0 ]; then NOTES="$NOTES$out"$'\n'; else FAILS="$FAILS$out"$'\n'; fi
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# The gate measures length from the git root, so a monorepo subfolder shares its baseline.
|
|
27
|
+
GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PROJECT_DIR")
|
|
28
|
+
[ -f "$HERE/line-limit.sh" ] && run env LINE_LIMIT_ROOT="$GIT_ROOT" bash "$HERE/line-limit.sh" --file "$FILE"
|
|
29
|
+
lint() { cd "$PROJECT_DIR" && node "$HERE/lint-ratchet.mjs" --file "$FILE"; }
|
|
30
|
+
[ -f "$HERE/lint-ratchet.mjs" ] && command -v node >/dev/null && run lint
|
|
31
|
+
|
|
32
|
+
if [ -n "$FAILS" ]; then
|
|
33
|
+
printf '%s%s' "$FAILS" "$NOTES" >&2
|
|
34
|
+
exit 2
|
|
35
|
+
fi
|
|
36
|
+
if [ -n "$NOTES" ] && declare -F json_string >/dev/null; then
|
|
37
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$(printf '%s' "$NOTES" | json_string)"
|
|
38
|
+
fi
|
|
39
|
+
exit 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
GAUNTLET_MAX_LINES=200
|
|
4
|
+
GAUNTLET_MUTATE=""
|
|
5
|
+
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
6
|
+
GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
|
|
7
|
+
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
|
|
8
|
+
# Components are length-checked but not mutated: their mutants are class names and JSX, not
|
|
9
|
+
# behaviour. Override in .claude/gauntlet.conf to mutate them too.
|
|
10
|
+
GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
|
|
11
|
+
|
|
12
|
+
load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
|
|
13
|
+
[ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
14
|
+
local conf="$1"; [ -f "$conf" ] || conf="$2"
|
|
15
|
+
if [ -f "$conf" ]; then . "$conf"; fi
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# set -f: unquoted, the list is glob-expanded before it is split, and */components/ui/*.tsx
|
|
19
|
+
# expanded into the very path it should match.
|
|
20
|
+
is_ignored() {
|
|
21
|
+
set -f
|
|
22
|
+
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
23
|
+
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
24
|
+
done
|
|
25
|
+
set +f
|
|
26
|
+
return 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
json_string() {
|
|
30
|
+
awk 'BEGIN { ORS = "" } {
|
|
31
|
+
gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, "")
|
|
32
|
+
gsub(/[\001-\010\013-\037\177]/, "") # JSON forbids raw control characters
|
|
33
|
+
gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t")
|
|
34
|
+
if (NR > 1) print "\\n"
|
|
35
|
+
print
|
|
36
|
+
}'
|
|
37
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
. "$HERE/gate-config.sh" || exit 1
|
|
7
|
+
ROOT="$(cd "${LINE_LIMIT_ROOT:-$(git -C "$HERE" rev-parse --show-toplevel 2>/dev/null || echo "$HERE/..")}" && pwd -P)"
|
|
8
|
+
load_gauntlet_conf "$ROOT/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
|
|
9
|
+
LIMIT=$GAUNTLET_MAX_LINES
|
|
10
|
+
BASELINE="$ROOT/.line-limit-baseline"
|
|
11
|
+
SELF="${HERE#"$ROOT"/}/line-limit.sh"
|
|
12
|
+
if ! [ "$LIMIT" -gt 0 ] 2>/dev/null; then
|
|
13
|
+
[ "${1:-}" = "--file" ] || echo "LINE LIMIT — off (GAUNTLET_MAX_LINES=$LIMIT)"
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
is_code() {
|
|
18
|
+
local ext="\\.($GAUNTLET_SOURCE_EXT|sh)$" skip="(^|/)node_modules/|^\\.claude/"
|
|
19
|
+
[[ $1 =~ $ext ]] && ! [[ $1 =~ $skip ]] && ! is_ignored "$1"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Paths are kept relative to ROOT so the baseline is portable between clones.
|
|
23
|
+
files() {
|
|
24
|
+
{ git -C "$ROOT" ls-files --cached --others --exclude-standard 2>/dev/null \
|
|
25
|
+
|| (cd "$ROOT" && find . -type f -not -path './.git/*' | sed 's|^\./||'); } \
|
|
26
|
+
| while IFS= read -r f; do is_code "$f" && [ -f "$ROOT/$f" ] && printf '%s/%s\n' "$ROOT" "$f"; done | sort
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
counts() {
|
|
30
|
+
while IFS= read -r f; do
|
|
31
|
+
printf '%s %s\n' "$(wc -l < "$f" | tr -d ' ')" "${f#"$ROOT"/}"
|
|
32
|
+
done
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ONE=""
|
|
36
|
+
if [ "${1:-}" = "--file" ]; then
|
|
37
|
+
ONE="$(cd "$(dirname -- "${2:-.}")" 2>/dev/null && pwd -P)/$(basename -- "${2:-.}")"
|
|
38
|
+
REL="${ONE#"$ROOT"/}"
|
|
39
|
+
[ "$REL" != "$ONE" ] && [ -f "$ONE" ] && is_code "$REL" || exit 0
|
|
40
|
+
git -C "$ROOT" check-ignore -q -- "$REL" 2>/dev/null && exit 0
|
|
41
|
+
NOW=$(printf '%s\n' "$ONE" | counts)
|
|
42
|
+
else
|
|
43
|
+
NOW=$(files | counts)
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# On an existing baseline this only banks: an entry may drop or go, never appear or grow.
|
|
47
|
+
if [ "${1:-}" = "--baseline" ]; then
|
|
48
|
+
if [ -f "$BASELINE" ]; then
|
|
49
|
+
printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '{ n = $1; p = $0; sub(/^[^ ]+ /, "", p) }
|
|
50
|
+
NR == FNR { was[p] = n; next } (p in was) && n > lim { print (n < was[p] ? n : was[p]), p }' "$BASELINE" - > "$BASELINE.tmp" \
|
|
51
|
+
&& mv "$BASELINE.tmp" "$BASELINE"
|
|
52
|
+
else
|
|
53
|
+
printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '$1 > lim' > "$BASELINE"
|
|
54
|
+
fi
|
|
55
|
+
n=$(wc -l < "$BASELINE" | tr -d ' ')
|
|
56
|
+
echo "line-limit: baseline written — $n file(s) over $LIMIT accepted as debt."
|
|
57
|
+
echo "Commit $BASELINE. From here they may shrink but never grow."
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
FAILED=0
|
|
62
|
+
NEW_OVER=""
|
|
63
|
+
GREW=""
|
|
64
|
+
SHRANK=""
|
|
65
|
+
|
|
66
|
+
while read -r n f; do
|
|
67
|
+
[ -z "$f" ] && continue
|
|
68
|
+
was=$(awk -v p="$f" '{ n = $1; sub(/^[^ ]+ /, "") } $0 == p { print n }' "$BASELINE" 2>/dev/null)
|
|
69
|
+
if [ -z "$was" ]; then
|
|
70
|
+
# Not accepted debt, so the real limit applies.
|
|
71
|
+
if [ "$n" -gt "$LIMIT" ]; then
|
|
72
|
+
NEW_OVER="$NEW_OVER $f — $n lines (limit $LIMIT)
|
|
73
|
+
"
|
|
74
|
+
FAILED=1
|
|
75
|
+
fi
|
|
76
|
+
elif [ "$n" -gt "$was" ]; then
|
|
77
|
+
GREW="$GREW $f — $n lines, was $was
|
|
78
|
+
"
|
|
79
|
+
FAILED=1
|
|
80
|
+
elif [ "$n" -lt "$was" ]; then
|
|
81
|
+
SHRANK="$SHRANK $f — $n lines, was $was
|
|
82
|
+
"
|
|
83
|
+
elif [ -n "$ONE" ]; then
|
|
84
|
+
echo "LINE LIMIT — $f is $n lines, over $LIMIT (old debt). Split it if this change allows."
|
|
85
|
+
fi
|
|
86
|
+
done <<EOF
|
|
87
|
+
$NOW
|
|
88
|
+
EOF
|
|
89
|
+
|
|
90
|
+
# A file in the baseline that no longer exists is debt that was paid by deleting
|
|
91
|
+
# or renaming it. Report it so the baseline can be trimmed, never fail on it.
|
|
92
|
+
GONE=""
|
|
93
|
+
if [ -z "$ONE" ] && [ -f "$BASELINE" ]; then
|
|
94
|
+
while read -r was f; do
|
|
95
|
+
[ -z "$f" ] && continue
|
|
96
|
+
[ -f "$ROOT/$f" ] || GONE="$GONE $f — gone (was $was lines)
|
|
97
|
+
"
|
|
98
|
+
done < "$BASELINE"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
if [ -n "$NEW_OVER" ]; then
|
|
102
|
+
echo "LINE LIMIT — over $LIMIT and not accepted debt:"
|
|
103
|
+
printf '%s' "$NEW_OVER"
|
|
104
|
+
echo " Split them."
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
if [ -n "$GREW" ]; then
|
|
108
|
+
echo "LINE LIMIT — accepted debt that grew:"
|
|
109
|
+
printf '%s' "$GREW"
|
|
110
|
+
echo " The baseline is a ceiling, not a licence. Take the addition elsewhere."
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [ -n "$SHRANK" ] || [ -n "$GONE" ]; then
|
|
114
|
+
echo "LINE LIMIT — debt paid down (not a failure):"
|
|
115
|
+
[ -n "$SHRANK" ] && printf '%s' "$SHRANK"
|
|
116
|
+
[ -n "$GONE" ] && printf '%s' "$GONE"
|
|
117
|
+
echo " Run: bash $SELF --baseline to bank it."
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
if [ "$FAILED" = 0 ] && [ -z "$SHRANK" ] && [ -z "$GONE" ] && [ -z "$ONE" ]; then
|
|
121
|
+
over=$(wc -l < "$BASELINE" 2>/dev/null | tr -d ' ')
|
|
122
|
+
echo "LINE LIMIT — ok. ${over:-0} file(s) of accepted debt, none grew."
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
exit $FAILED
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { brokenRules, loadEditRules, projectFiles, textFindings } from "./text-rules.mjs";
|
|
7
|
+
import { mergeBaseline, readBaseline, writeBaseline } from "./ratchet-baseline.mjs";
|
|
8
|
+
|
|
9
|
+
const ROOT = realpathSync(process.cwd());
|
|
10
|
+
const BASELINE = resolve(ROOT, ".lint-baseline");
|
|
11
|
+
const ESLINT = resolve(ROOT, "node_modules/.bin/eslint");
|
|
12
|
+
const SELF = relative(ROOT, fileURLToPath(import.meta.url));
|
|
13
|
+
let TEXT = [];
|
|
14
|
+
let LESSONS = [];
|
|
15
|
+
|
|
16
|
+
const isTest = (f) => /\.(test|spec)\.|(^|\/)(test|tests|__tests__)\//.test(f);
|
|
17
|
+
|
|
18
|
+
// A test's describe() callback is one long function by design, and React
|
|
19
|
+
// components are left unannotated by convention, so each rule names its files.
|
|
20
|
+
const RULES = [
|
|
21
|
+
{
|
|
22
|
+
id: "max-lines-per-function",
|
|
23
|
+
skill: "code-structure",
|
|
24
|
+
config: ["error", { max: 30, skipBlankLines: true, skipComments: true }],
|
|
25
|
+
applies: (f) => /\.(m?js|jsx|ts|tsx)$/.test(f) && !isTest(f),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "@typescript-eslint/explicit-module-boundary-types",
|
|
29
|
+
skill: "typescript-best-practices",
|
|
30
|
+
config: "error",
|
|
31
|
+
applies: (f) => /\.ts$/.test(f) && !isTest(f),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// skills/ is where this repo keeps the skills it ships; a consumer has them under .claude/skills/.
|
|
36
|
+
const hasSkill = (name) =>
|
|
37
|
+
existsSync(resolve(ROOT, ".claude/skills", name, "SKILL.md")) || existsSync(resolve(ROOT, "skills", name, "SKILL.md"));
|
|
38
|
+
const LINT = RULES.filter((r) => hasSkill(r.skill));
|
|
39
|
+
|
|
40
|
+
// A plugin rule the project's config cannot load makes ESLint crash, so retry without it.
|
|
41
|
+
function eslint(rules, target) {
|
|
42
|
+
const results = eslintOnce(rules, target);
|
|
43
|
+
const core = rules.filter((r) => !r.id.includes("/"));
|
|
44
|
+
if (results || core.length === rules.length) return { results, ran: rules };
|
|
45
|
+
return { results: eslintOnce(core, target), ran: core };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function eslintOnce(rules, target) {
|
|
49
|
+
const args = ["-f", "json"];
|
|
50
|
+
for (const r of rules) args.push("--rule", JSON.stringify({ [r.id]: r.config }));
|
|
51
|
+
args.push(target);
|
|
52
|
+
const run = spawnSync(ESLINT, args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(run.stdout);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function eslintFindings(results) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const file of results) {
|
|
63
|
+
const rel = relative(ROOT, file.filePath);
|
|
64
|
+
for (const m of file.messages) {
|
|
65
|
+
const rule = RULES.find((r) => r.id === m.ruleId);
|
|
66
|
+
if (rule && rule.applies(rel)) out.push({ rel, rule: m.ruleId, line: m.line, message: m.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countBy(list) {
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const f of list) {
|
|
75
|
+
const key = `${f.rule} ${f.rel}`;
|
|
76
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
return counts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function baseline(only, newOnly) {
|
|
82
|
+
const was = readBaseline(BASELINE);
|
|
83
|
+
const pick = (r) => (only ? r.id === only : !newOnly || !was.rules.has(r.id));
|
|
84
|
+
const lint = existsSync(ESLINT) ? LINT.filter(pick) : [];
|
|
85
|
+
const text = TEXT.filter(pick);
|
|
86
|
+
if (newOnly && was.exists && lint.length + text.length === 0) return 0;
|
|
87
|
+
const { results, ran } = lint.length ? eslint(lint, ".") : { results: [], ran: [] };
|
|
88
|
+
if (!results) {
|
|
89
|
+
console.error("lint-ratchet: eslint gave no JSON; baseline not written.");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const found = [...eslintFindings(results), ...projectFiles(ROOT).flatMap((rel) => textFindings(text, ROOT, rel))];
|
|
93
|
+
const judged = new Set([...ran, ...text].map((r) => r.id));
|
|
94
|
+
const n = writeBaseline(BASELINE, mergeBaseline(was, countBy(found), judged), new Set([...was.rules, ...judged]));
|
|
95
|
+
console.log(`lint-ratchet: baseline written — ${n} rule/file pair(s) accepted as debt.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scan(rel) {
|
|
100
|
+
const lint = existsSync(ESLINT) ? LINT.filter((r) => r.applies(rel)) : [];
|
|
101
|
+
const { results, ran } = lint.length ? eslint(lint, rel) : { results: null, ran: [] };
|
|
102
|
+
const text = TEXT.filter((r) => r.applies(rel));
|
|
103
|
+
return {
|
|
104
|
+
rules: [...(results ? ran : []), ...text],
|
|
105
|
+
found: [...(results ? eslintFindings(results) : []), ...textFindings(text, ROOT, rel)],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkFile(path) {
|
|
110
|
+
const abs = resolve(ROOT, path);
|
|
111
|
+
const rel = relative(ROOT, existsSync(abs) ? realpathSync(abs) : abs);
|
|
112
|
+
if (rel.startsWith("..") || spawnSync("git", ["check-ignore", "-q", "--", rel], { cwd: ROOT }).status === 0) return 0;
|
|
113
|
+
const { rules, found } = scan(rel);
|
|
114
|
+
const was = readBaseline(BASELINE);
|
|
115
|
+
let failed = 0;
|
|
116
|
+
for (const rule of rules) {
|
|
117
|
+
const hits = found.filter((f) => f.rule === rule.id);
|
|
118
|
+
const before = was.counts.get(`${rule.id} ${rel}`) ?? 0;
|
|
119
|
+
if (hits.length > before && !was.rules.has(rule.id)) {
|
|
120
|
+
console.log(`NOTE — ${rule.id} has no baseline yet, so its ${hits.length} finding(s) in ${rel} do not block. Run node ${SELF} --baseline-new to record old code.`);
|
|
121
|
+
} else if (hits.length > before) {
|
|
122
|
+
failed = 1;
|
|
123
|
+
console.log(`LINT — ${rel}: ${hits.length} × ${rule.id}, was ${before}. Fix the new one(s):`);
|
|
124
|
+
for (const h of hits) console.log(` ${rel}:${h.line} ${h.message}`);
|
|
125
|
+
} else if (hits.length > 0) {
|
|
126
|
+
console.log(`NOTE — ${rel} has ${hits.length} old ${rule.id} finding(s). Fix them if this change allows.`);
|
|
127
|
+
} else if (before > 0) {
|
|
128
|
+
console.log(`NOTE — ${rel}: ${rule.id} debt paid down. Run node ${SELF} --baseline ${rule.id} to bank it.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const lesson of LESSONS.filter((l) => l.applies(rel))) console.log(`LESSON — ${lesson.message}`);
|
|
132
|
+
return failed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function selftest() {
|
|
136
|
+
const broken = brokenRules(TEXT);
|
|
137
|
+
for (const b of broken) console.log(`RULE — ${b}`);
|
|
138
|
+
if (broken.length === 0) console.log(`lint-ratchet: ${TEXT.length} text rule(s) catch their bad example and pass their good one.`);
|
|
139
|
+
return broken.length ? 1 : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function main(mode, arg) {
|
|
143
|
+
({ text: TEXT, lessons: LESSONS } = loadEditRules(ROOT));
|
|
144
|
+
if (mode === "--baseline") return baseline(arg, false);
|
|
145
|
+
if (mode === "--baseline-new") return baseline(undefined, true);
|
|
146
|
+
if (mode === "--file") return checkFile(arg ?? "");
|
|
147
|
+
if (mode === "--selftest") return selftest();
|
|
148
|
+
console.error("usage: lint-ratchet.mjs --baseline [rule-id] | --baseline-new | --file <path> | --selftest");
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const [mode, arg] = process.argv.slice(2);
|
|
153
|
+
try {
|
|
154
|
+
process.exit(main(mode, arg));
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.log(`NOTE — .edit-rules.json could not be used, so its rules did not run: ${err.message}`);
|
|
157
|
+
process.exit(mode === "--file" ? 0 : 1);
|
|
158
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const ruleOf = (key) => key.split(" ")[0];
|
|
4
|
+
|
|
5
|
+
// "# rules:" names every rule baselined so far, since a rule with no hits leaves no other line.
|
|
6
|
+
export function readBaseline(path) {
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
const rules = new Set();
|
|
9
|
+
if (!existsSync(path)) return { counts, rules, exists: false };
|
|
10
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
11
|
+
if (line.startsWith("# rules:")) {
|
|
12
|
+
for (const id of line.slice(8).trim().split(/\s+/).filter(Boolean)) rules.add(id);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const m = line.match(/^(\d+) (\S+) (.+)$/);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
counts.set(`${m[2]} ${m[3]}`, Number(m[1]));
|
|
18
|
+
rules.add(m[2]);
|
|
19
|
+
}
|
|
20
|
+
return { counts, rules, exists: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A rule baselined before only banks: its debt may drop or go, never appear or grow.
|
|
24
|
+
export function mergeBaseline(was, now, judged) {
|
|
25
|
+
const merged = new Map([...was.counts].filter(([key]) => !judged.has(ruleOf(key))));
|
|
26
|
+
for (const [key, n] of was.counts) {
|
|
27
|
+
if (judged.has(ruleOf(key)) && now.has(key)) merged.set(key, Math.min(n, now.get(key)));
|
|
28
|
+
}
|
|
29
|
+
for (const [key, n] of now) {
|
|
30
|
+
if (judged.has(ruleOf(key)) && !was.rules.has(ruleOf(key))) merged.set(key, n);
|
|
31
|
+
}
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeBaseline(path, merged, rules) {
|
|
36
|
+
const lines = [...merged].map(([key, n]) => `${n} ${key}`).sort();
|
|
37
|
+
writeFileSync(path, [`# rules: ${[...rules].sort().join(" ")}`, ...lines, ""].join("\n"));
|
|
38
|
+
return lines.length;
|
|
39
|
+
}
|
package/hooks/ship-gate-files.sh
CHANGED
|
@@ -20,7 +20,7 @@ changed_tests() { # changed_tests <family>
|
|
|
20
20
|
files_context() { # files_context <owner> <owned files>
|
|
21
21
|
fam=$(family_of "$1/.")
|
|
22
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"
|
|
23
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
|
|
24
24
|
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | grep -vE "$TEST_RE" \
|
|
25
25
|
| grep -vxF -f <(printf '%s\n' "$2") | while IFS= read -r f; do
|
|
26
26
|
[ -n "$f" ] || continue
|
|
@@ -110,3 +110,22 @@ stryker_findings() { # stryker_findings <output> <globbed paths, comma-wrapped>
|
|
|
110
110
|
END { if (pend != "" && kind == "s") print pend }
|
|
111
111
|
'
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
# A survivor needs a test run against it. Survivors with 0 tests per mutant and
|
|
115
|
+
# nothing killed mean the runner filtered every test out (stryker-js#6210, Vitest 5).
|
|
116
|
+
stryker_zero_tests() { # stryker_zero_tests <output> <label> <seconds>
|
|
117
|
+
grep -q '^\[Survived\]' <<< "$1" && grep -q 'Ran 0\.00 tests per mutant' <<< "$1" || return 1
|
|
118
|
+
[ "$(awk -F'|' '/^ *All files/ { gsub(/ /, "", $4); print $4 }' <<< "$1")" = 0 ] || return 1
|
|
119
|
+
echo " $2 stryker (${3}s) — its survivors ran 0 tests, so they prove nothing and are dropped."
|
|
120
|
+
echo " On Vitest 5 this is stryker-js#6210: pin vitest@4 until a fixed runner ships."
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Sets ALL from a Stryker run. Fails when a broken run leaves nothing provable.
|
|
124
|
+
stryker_judge() { # uses OUT GLOBBED FCTX RC owner label T from ship-gate.sh
|
|
125
|
+
ALL=$(stryker_findings "$OUT" "$GLOBBED")
|
|
126
|
+
if stryker_zero_tests "$OUT" "$label" "$T"; then
|
|
127
|
+
ALL=$(grep -v '^\[Survived\]' <<< "$ALL"); [ -n "$ALL" ]; return
|
|
128
|
+
fi
|
|
129
|
+
[ -n "$FCTX" ] && [ "$RC" -eq 0 ] && files_record "$FCTX" "$owner" "$ALL"
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
@@ -139,7 +139,7 @@ project_key() { # project_key <owner> <tool>
|
|
|
139
139
|
fam=$(family_of "$1/.")
|
|
140
140
|
{ printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
|
|
141
141
|
"$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
|
|
142
|
-
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
|
|
142
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
|
|
143
143
|
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
|
|
144
144
|
[ -n "$f" ] || continue
|
|
145
145
|
case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
|
|
@@ -107,6 +107,20 @@ structure_fail() {
|
|
|
107
107
|
# An agent that pipes the gate through `tail` cuts the findings off; the last line still says where they are.
|
|
108
108
|
gate_report() {
|
|
109
109
|
REPORT="/tmp/claude-shipgate-$1.log"
|
|
110
|
-
exec > >(tee "$REPORT") 2>&1
|
|
111
|
-
|
|
110
|
+
exec 3>&1 4>&2 > >(tee "$REPORT") 2>&1
|
|
111
|
+
TEE_PID=${!:-} # bash before 4.4 does not set $! for process substitution
|
|
112
|
+
trap 'gate_exit $?' EXIT
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# A FAIL goes to the diary /add-rule reads, so a test gap the gate keeps finding can become a rule.
|
|
116
|
+
gate_exit() {
|
|
117
|
+
echo "ship-gate: full output in $REPORT"
|
|
118
|
+
[ "$1" = 1 ] || return 0
|
|
119
|
+
exec 1>&3 2>&4
|
|
120
|
+
# A child left running (a dev server) can hold tee open forever; give it 2s, not a hang.
|
|
121
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do [ -n "$TEE_PID" ] && kill -0 "$TEE_PID" 2>/dev/null || break; sleep 0.2; done
|
|
122
|
+
mkdir -p "$PROJECT_DIR/.claude"
|
|
123
|
+
printf '{"ts":"%s","branch":"%s","status":"FAIL","report":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
124
|
+
"$(git branch --show-current 2>/dev/null | json_string)" "$(json_string < "$REPORT")" \
|
|
125
|
+
>> "$PROJECT_DIR/.claude/gate-findings.jsonl"
|
|
112
126
|
}
|
package/hooks/ship-gate.sh
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
set -uo pipefail
|
|
48
48
|
|
|
49
49
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
50
|
-
. "$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
|
+
. "$HERE/gate-config.sh" && . "$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; }
|
|
51
51
|
|
|
52
52
|
MODE=""
|
|
53
53
|
case "${1:-}" in
|
|
@@ -73,20 +73,7 @@ if [ -n "$ROOT" ]; then
|
|
|
73
73
|
PROJECT_DIR="$ROOT"
|
|
74
74
|
fi
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
GAUNTLET_MUTATE=""
|
|
78
|
-
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
79
|
-
GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
|
|
80
|
-
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
|
|
81
|
-
# Length-checked and skill-audited like everything else, but not mutated.
|
|
82
|
-
# Mutation pays on logic and burns time on presentation: a component's mutants
|
|
83
|
-
# are class names, copy and JSX shape, none of which is behaviour, and one
|
|
84
|
-
# session spent an evening killing them. Logic that lives inside a component is
|
|
85
|
-
# logic that wants extracting — which the 200-line rule already pushes toward.
|
|
86
|
-
# Override in .claude/gauntlet.conf to mutate components too.
|
|
87
|
-
GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
|
|
88
|
-
[ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
89
|
-
CONF=.claude/gauntlet.conf; [ -f "$CONF" ] || CONF="$HERE/../gauntlet.conf"; [ -f "$CONF" ] && . "$CONF"
|
|
76
|
+
load_gauntlet_conf .claude/gauntlet.conf "$HERE/../gauntlet.conf"
|
|
90
77
|
|
|
91
78
|
# ------------------------------------------------------------------- the scope
|
|
92
79
|
BASE="${1:-}"
|
|
@@ -135,17 +122,6 @@ SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
|
|
|
135
122
|
# uncovered line started failing the gate: vitest.config.ts has no tests, will
|
|
136
123
|
# never have tests, and is not a thing anyone writes a test for. Left in, every
|
|
137
124
|
# diff that touches a config reports findings nobody can close.
|
|
138
|
-
# set -f matters: an unquoted list is glob-expanded before it is split, so
|
|
139
|
-
# */components/ui/*.tsx turned itself into the very path it was meant to match
|
|
140
|
-
# and then matched nothing.
|
|
141
|
-
is_ignored() {
|
|
142
|
-
set -f
|
|
143
|
-
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
144
|
-
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
145
|
-
done
|
|
146
|
-
set +f
|
|
147
|
-
return 1
|
|
148
|
-
}
|
|
149
125
|
FILES=$(printf '%s\n' "$SOURCED" | while IFS= read -r f; do
|
|
150
126
|
[ -n "$f" ] && ! is_ignored "$f" && printf '%s\n' "$f"
|
|
151
127
|
done)
|
|
@@ -190,7 +166,7 @@ if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
|
|
|
190
166
|
while IFS= read -r f; do
|
|
191
167
|
n=$(wc -l < "$f")
|
|
192
168
|
[ "$n" -gt "$LARGEST" ] && LARGEST=$n
|
|
193
|
-
|
|
169
|
+
out=$(LINE_LIMIT_ROOT="$PWD" bash "$HERE/line-limit.sh" --file "$f") || OVER="$OVER$(printf '%s\n' "$out" | grep -E '^ .* — [0-9]+ lines')
|
|
194
170
|
"
|
|
195
171
|
done <<< "$FILES"
|
|
196
172
|
fi
|
|
@@ -324,8 +300,7 @@ for owner in $OWNERS; do
|
|
|
324
300
|
[ "$TOOL" = mutmut ] && [ $RC -eq 0 ] && { printf '%s\n' "$OUT" > "$PKEY.out"; cp "$MLOG" "$PKEY.log" 2>/dev/null; }
|
|
325
301
|
[ -n "$SCOPE_RE" ] && [ $RC -eq 0 ] && OUT=$(printf '%s\n' "$OUT" | grep -E "^[[:space:]]*($SCOPE_RE)\.x")
|
|
326
302
|
if [ "$TOOL" = stryker ]; then
|
|
327
|
-
|
|
328
|
-
[ -n "$FCTX" ] && [ $RC -eq 0 ] && files_record "$FCTX" "$owner" "$ALL"
|
|
303
|
+
stryker_judge || { [ "$STATUS" = 0 ] && STATUS=2; continue; }
|
|
329
304
|
else
|
|
330
305
|
ALL=$(printf '%s\n' "$OUT" | grep -E '[Ss]urvived' \
|
|
331
306
|
| grep -vE '^[[:space:]]*[#│|+-]|# *surviv')
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# SubagentStop: appends each gauntlet-skills verdict to .claude/skill-flags.jsonl.
|
|
3
2
|
|
|
4
3
|
INPUT=$(cat | tr -d '\n')
|
|
5
4
|
|
|
6
5
|
case "$INPUT" in
|
|
7
|
-
*'"agent_type":"gauntlet-skills"'*) ;;
|
|
6
|
+
*'"agent_type":"gauntlet-skills"'*) LOG=skill-flags.jsonl ;;
|
|
7
|
+
*'\"failure_scenario\"'*) LOG=review-findings.jsonl ;;
|
|
8
8
|
*) exit 0 ;;
|
|
9
9
|
esac
|
|
10
10
|
|
|
@@ -19,7 +19,9 @@ TRANSCRIPT=$(printf '%s' "$INPUT" | grep -o '"agent_transcript_path":"[^"]*"' |
|
|
|
19
19
|
MODEL=""
|
|
20
20
|
[ -f "$TRANSCRIPT" ] && MODEL=$(grep -o '"model":"[^"]*"' "$TRANSCRIPT" | tail -1 | sed 's/.*:"//; s/"$//')
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
. "$SCRIPT_DIR/gate-config.sh" 2>/dev/null || json_string() { tr -cd '[:alnum:]/._-'; }
|
|
23
|
+
BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null | json_string)
|
|
24
|
+
printf '{"ts":"%s","branch":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BRANCH" "$MODEL" "$REPLY" \
|
|
25
|
+
>> "$PROJECT_DIR/.claude/$LOG"
|
|
24
26
|
|
|
25
27
|
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function loadEditRules(root) {
|
|
6
|
+
const file = resolve(root, ".edit-rules.json");
|
|
7
|
+
const { text = [], lessons = [] } = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
8
|
+
const withFiles = (r) => ({ ...r, applies: (f) => new RegExp(r.files).test(f) });
|
|
9
|
+
return { text: text.map(withFiles), lessons: lessons.map(withFiles) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function matchLines(rule, content) {
|
|
13
|
+
const re = new RegExp(rule.pattern, `g${(rule.flags ?? "").replace("g", "")}`);
|
|
14
|
+
return [...content.matchAll(re)].map((m) => content.slice(0, m.index).split("\n").length);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function textFindings(rules, root, rel) {
|
|
18
|
+
const own = rules.filter((r) => r.applies(rel));
|
|
19
|
+
const path = resolve(root, rel);
|
|
20
|
+
if (own.length === 0 || !existsSync(path)) return [];
|
|
21
|
+
const content = readFileSync(path, "utf8");
|
|
22
|
+
return own.flatMap((r) => matchLines(r, content).map((line) => ({ rel, rule: r.id, line, message: r.message })));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function projectFiles(root) {
|
|
26
|
+
const args = ["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
27
|
+
const run = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
28
|
+
return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : walk(root, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(root, dir) {
|
|
32
|
+
return readdirSync(resolve(root, dir), { withFileTypes: true }).flatMap((e) => {
|
|
33
|
+
if (e.name === "node_modules" || e.name === ".git") return [];
|
|
34
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
35
|
+
return e.isDirectory() ? walk(root, rel) : [rel];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function brokenRules(rules) {
|
|
40
|
+
const broken = [];
|
|
41
|
+
for (const r of rules) {
|
|
42
|
+
if (matchLines(r, r.bad ?? "").length === 0) broken.push(`${r.id}: its bad example is not caught`);
|
|
43
|
+
if (matchLines(r, r.good ?? "").length > 0) broken.push(`${r.id}: its good example is flagged`);
|
|
44
|
+
}
|
|
45
|
+
return broken;
|
|
46
|
+
}
|
package/lib/hook-settings.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
|
12
12
|
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
13
13
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
14
14
|
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
15
|
+
const EDIT_CHECK_FILENAME = "edit-check.sh";
|
|
15
16
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
16
17
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
17
18
|
|
|
@@ -96,6 +97,12 @@ export async function registerHooks(settingsPath) {
|
|
|
96
97
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
const editCheckEntry = {
|
|
101
|
+
matcher: "Write|Edit|MultiEdit",
|
|
102
|
+
hooks: [{ type: "command", command: `$CLAUDE_PROJECT_DIR/.claude/hooks/${EDIT_CHECK_FILENAME}` }],
|
|
103
|
+
};
|
|
104
|
+
upsert(settings.hooks.PostToolUse, EDIT_CHECK_FILENAME, editCheckEntry);
|
|
105
|
+
|
|
99
106
|
// Register SessionStart version check. No matcher: startup and resume both
|
|
100
107
|
// want it, and the script's own daily stamp keeps a burst of resumes quiet.
|
|
101
108
|
const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
|
package/lib/scaffold-stryker.mjs
CHANGED
|
@@ -35,6 +35,7 @@ const CONFIG = {
|
|
|
35
35
|
tempDirName: ".stryker-tmp",
|
|
36
36
|
};
|
|
37
37
|
import { IGNORER, IGNORER_VERSION } from "./stryker-ignorer.mjs";
|
|
38
|
+
import { TS7_TSCONFIG, usesTs7 } from "./stryker-ts7.mjs";
|
|
38
39
|
|
|
39
40
|
async function exists(p) {
|
|
40
41
|
try {
|
|
@@ -69,6 +70,7 @@ export async function scaffoldStryker(projectDir, rel) {
|
|
|
69
70
|
const kept = [];
|
|
70
71
|
const patched = [];
|
|
71
72
|
const unreadable = [];
|
|
73
|
+
const ts7 = await usesTs7(dir, projectDir);
|
|
72
74
|
|
|
73
75
|
// A project that already runs Stryker keeps every setting it chose. The two
|
|
74
76
|
// entries that wire the ignorer in are added, because a config written before
|
|
@@ -95,8 +97,9 @@ export async function scaffoldStryker(projectDir, rel) {
|
|
|
95
97
|
const coverage = cfg.coverageAnalysis;
|
|
96
98
|
const canSetStatic = coverage === undefined || coverage === "perTest";
|
|
97
99
|
const needsStatic = cfg.ignoreStatic !== true && canSetStatic;
|
|
100
|
+
const needsTsconfig = ts7 && cfg.tsconfigFile === undefined;
|
|
98
101
|
|
|
99
|
-
if (!needsIgnorer && !needsStatic) {
|
|
102
|
+
if (!needsIgnorer && !needsStatic && !needsTsconfig) {
|
|
100
103
|
kept.push(`${at}stryker.config.json`);
|
|
101
104
|
} else {
|
|
102
105
|
if (needsIgnorer) {
|
|
@@ -107,12 +110,14 @@ export async function scaffoldStryker(projectDir, rel) {
|
|
|
107
110
|
cfg.coverageAnalysis = "perTest";
|
|
108
111
|
cfg.ignoreStatic = true;
|
|
109
112
|
}
|
|
113
|
+
if (needsTsconfig) cfg.tsconfigFile = TS7_TSCONFIG;
|
|
110
114
|
await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
111
115
|
patched.push(`${at}stryker.config.json`);
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
} else {
|
|
115
|
-
|
|
119
|
+
const config = ts7 ? { ...CONFIG, tsconfigFile: TS7_TSCONFIG } : CONFIG;
|
|
120
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
116
121
|
wrote.push(`${at}stryker.config.json`);
|
|
117
122
|
}
|
|
118
123
|
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { registerHooks } from "./hook-settings.mjs";
|
|
2
2
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { join, resolve, dirname } from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
|
|
7
8
|
// Installs the hook scripts and registers them in .claude/settings.json.
|
|
@@ -17,17 +18,21 @@ import { homedir } from "node:os";
|
|
|
17
18
|
// explicitly applied before the first edit
|
|
18
19
|
// gauntlet.sh Stop — fast typecheck and tests on the diff
|
|
19
20
|
// ship-gate.sh file length and mutation, behind an exit code
|
|
21
|
+
// gate-config.sh sourced by ship-gate.sh — shared helpers
|
|
20
22
|
// ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
|
|
21
23
|
// ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
|
|
22
24
|
// version-check.sh SessionStart — a line when a newer catalog exists
|
|
23
25
|
// skill-flags-record.sh SubagentStop — logs each skills-audit verdict
|
|
24
26
|
// skill-report.mjs read by /skill-report — tallies that log
|
|
27
|
+
// edit-check.sh PostToolUse — line-limit.sh, lint-ratchet.mjs and
|
|
28
|
+
// text-rules.mjs on each edited file
|
|
25
29
|
|
|
26
30
|
const GATE_FILENAME = "skill-gate.sh";
|
|
27
31
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
28
32
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
29
33
|
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
30
34
|
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
35
|
+
const GATE_CONFIG_FILENAME = "gate-config.sh";
|
|
31
36
|
const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
|
|
32
37
|
const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
|
|
33
38
|
const SHIP_GATE_FILES_FILENAME = "ship-gate-files.sh";
|
|
@@ -35,6 +40,11 @@ const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
|
35
40
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
36
41
|
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
37
42
|
const SKILL_REPORT_FILENAME = "skill-report.mjs";
|
|
43
|
+
const EDIT_CHECK_FILENAME = "edit-check.sh";
|
|
44
|
+
const LINE_LIMIT_FILENAME = "line-limit.sh";
|
|
45
|
+
const LINT_RATCHET_FILENAME = "lint-ratchet.mjs";
|
|
46
|
+
const TEXT_RULES_FILENAME = "text-rules.mjs";
|
|
47
|
+
const RATCHET_BASELINE_FILENAME = "ratchet-baseline.mjs";
|
|
38
48
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
39
49
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
40
50
|
|
|
@@ -99,6 +109,20 @@ export async function writeGauntletConf(targetDir, testCommand) {
|
|
|
99
109
|
return confPath;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
// Before the edit hook is registered, so code that predates it never blocks an edit.
|
|
113
|
+
// Only rules never baselined are recorded: re-recording a known one would bank growth.
|
|
114
|
+
function writeBaselines(dir, hooksDir) {
|
|
115
|
+
const gitRoot = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim();
|
|
116
|
+
const root = gitRoot || dir;
|
|
117
|
+
if (!existsSync(join(root, ".line-limit-baseline"))) {
|
|
118
|
+
spawnSync("bash", [join(hooksDir, LINE_LIMIT_FILENAME), "--baseline"],
|
|
119
|
+
{ cwd: root, env: { ...process.env, LINE_LIMIT_ROOT: root }, stdio: "ignore" });
|
|
120
|
+
}
|
|
121
|
+
console.log(" Recording old code as baseline debt (runs ESLint once per new rule)...");
|
|
122
|
+
spawnSync(process.execPath, [join(hooksDir, LINT_RATCHET_FILENAME), "--baseline-new"],
|
|
123
|
+
{ cwd: dir, stdio: "ignore", timeout: 600_000 });
|
|
124
|
+
}
|
|
125
|
+
|
|
102
126
|
export async function setupHook(targetDir = process.cwd()) {
|
|
103
127
|
const resolved = resolve(targetDir);
|
|
104
128
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
@@ -111,6 +135,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
111
135
|
APPLICATION_GATE_FILENAME,
|
|
112
136
|
GAUNTLET_FILENAME,
|
|
113
137
|
SHIP_GATE_FILENAME,
|
|
138
|
+
GATE_CONFIG_FILENAME,
|
|
114
139
|
SHIP_GATE_PROJECTS_FILENAME,
|
|
115
140
|
SHIP_GATE_STRUCTURE_FILENAME,
|
|
116
141
|
SHIP_GATE_FILES_FILENAME,
|
|
@@ -118,6 +143,11 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
118
143
|
VERSION_CHECK_FILENAME,
|
|
119
144
|
SKILL_FLAGS_FILENAME,
|
|
120
145
|
SKILL_REPORT_FILENAME,
|
|
146
|
+
EDIT_CHECK_FILENAME,
|
|
147
|
+
LINE_LIMIT_FILENAME,
|
|
148
|
+
LINT_RATCHET_FILENAME,
|
|
149
|
+
TEXT_RULES_FILENAME,
|
|
150
|
+
RATCHET_BASELINE_FILENAME,
|
|
121
151
|
]) {
|
|
122
152
|
const dest = join(hooksDir, name);
|
|
123
153
|
await writeFile(dest, await readHookSource(name), { mode: 0o755 });
|
|
@@ -131,6 +161,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
131
161
|
// not present — fine
|
|
132
162
|
}
|
|
133
163
|
|
|
164
|
+
writeBaselines(resolved, hooksDir);
|
|
134
165
|
await registerHooks(settingsPath);
|
|
135
166
|
|
|
136
167
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
@@ -140,5 +171,6 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
140
171
|
console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
|
|
141
172
|
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
142
173
|
console.log(` Hook installed: .claude/hooks/${SKILL_FLAGS_FILENAME}`);
|
|
174
|
+
console.log(" Hook installed: .claude/hooks/edit-check.sh (baselines: .line-limit-baseline, .lint-baseline)");
|
|
143
175
|
console.log(` Settings updated: .claude/settings.json`);
|
|
144
176
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Stryker rewrites tsconfig through TypeScript's JS API, which 7 no longer has
|
|
5
|
+
// (stryker-js#6111). Naming a file that does not exist skips the rewrite.
|
|
6
|
+
export const TS7_TSCONFIG = "no-tsconfig-rewrite-typescript-7-has-no-compiler-api.json";
|
|
7
|
+
|
|
8
|
+
async function readJson(p) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(await readFile(p, "utf-8"));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A range like "catalog:" or "workspace:*" carries no number, so the installed copy wins.
|
|
17
|
+
export async function usesTs7(...dirs) {
|
|
18
|
+
for (const d of dirs) {
|
|
19
|
+
const installed = await readJson(join(d, "node_modules", "typescript", "package.json"));
|
|
20
|
+
const pkg = await readJson(join(d, "package.json"));
|
|
21
|
+
const version = installed?.version ?? pkg?.devDependencies?.typescript ?? pkg?.dependencies?.typescript;
|
|
22
|
+
const major = Number(version?.match(/\d+/)?.[0]);
|
|
23
|
+
if (major) return major >= 7;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spardutti/claude-skills",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.52.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-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs 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/gate-config.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 ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs hooks/",
|
|
15
15
|
"postpack": "git checkout -- README.md && rm -rf hooks"
|
|
16
16
|
},
|
|
17
17
|
"engines": {
|