@spardutti/claude-skills 2.20.1 → 2.22.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 +3 -0
- package/bin/cli.mjs +53 -1
- package/lib/local.mjs +15 -0
- package/lib/scaffold-stryker.mjs +142 -0
- package/lib/setup-hook.mjs +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ Cross-cutting craft — applies to any stack, any language.
|
|
|
88
88
|
|-------|----------------|
|
|
89
89
|
| `code-structure` 📦 | Single Responsibility (when to split) + Avoid Hasty Abstractions (when *not* to extract) — hard size limits, separation of concerns, the Rule of Three |
|
|
90
90
|
| `debugging` | Root cause before patching — reproduce, trace the failure backward to its origin, one hypothesis at a time, the 3-attempts-then-question-the-design rule |
|
|
91
|
+
| `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 |
|
|
92
|
+
| `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 |
|
|
91
93
|
| `typescript-best-practices` | TypeScript 7.x — type design, generics, type guards, `satisfies`, `using`, error handling, `tsconfig` |
|
|
92
94
|
| `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, diff-scoped runs, the traps, and working score thresholds |
|
|
93
95
|
| `security-practices` | OWASP Top 10 prevention, input validation, auth, SQL injection, XSS, CSRF, secure defaults |
|
|
@@ -106,6 +108,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
106
108
|
| `/deep-review` | Multi-agent deep code review — 5 parallel subagents catch guard bypasses, lost async state, wrong-table queries, dead references, protocol violations |
|
|
107
109
|
| `/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 |
|
|
108
110
|
| `/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 |
|
|
111
|
+
| `/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. Links to specs, commits, and PRs instead of restating them |
|
|
109
112
|
|
|
110
113
|
## How It Works
|
|
111
114
|
|
package/bin/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../
|
|
|
10
10
|
import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
|
|
11
11
|
import { setupHook, detectStack, writeGauntletConf } from "../lib/setup-hook.mjs";
|
|
12
12
|
import { makeLocalSource, reportToolNeeds } from "../lib/local.mjs";
|
|
13
|
+
import { scaffoldStryker } from "../lib/scaffold-stryker.mjs";
|
|
13
14
|
import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
|
|
14
15
|
import {
|
|
15
16
|
readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
|
|
@@ -209,12 +210,63 @@ async function main() {
|
|
|
209
210
|
if (!selectedSkills.some((s) => s.dirName === "testing-best-practices")) {
|
|
210
211
|
console.log(` ${chalk.dim("Setting these up has traps — install the testing-best-practices skill for MUTATION-TESTING.md.")}`);
|
|
211
212
|
}
|
|
212
|
-
for (const n of needs) {
|
|
213
|
+
for (const n of needs.filter((n) => !n.ignorerOnly)) {
|
|
213
214
|
console.log(` ${chalk.bold(n.label)} needs ${n.tool}`);
|
|
214
215
|
console.log(` ${chalk.cyan(n.install)}`);
|
|
215
216
|
console.log(` ${chalk.dim(n.config)}`);
|
|
216
217
|
for (const line of n.also ?? []) console.log(` ${chalk.dim(line)}`);
|
|
217
218
|
}
|
|
219
|
+
for (const n of needs.filter((n) => n.ignorerOnly)) {
|
|
220
|
+
console.log(` ${chalk.bold(n.label)} runs ${n.tool} without the class-name ignorer`);
|
|
221
|
+
console.log(` ${chalk.dim("~40% of a first React run is mutants on className strings, which no test can honestly kill")}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Printing five steps meant every project was set up by hand, and the one
|
|
225
|
+
// step people skipped was the class-name ignorer — the reason a first run
|
|
226
|
+
// comes back with dozens of mutants on className strings that no test can
|
|
227
|
+
// honestly kill. Write the two files instead of describing them.
|
|
228
|
+
const stryker = needs.filter((n) => n.tool === "Stryker");
|
|
229
|
+
if (stryker.length > 0) {
|
|
230
|
+
console.log();
|
|
231
|
+
const doScaffold = await confirm({
|
|
232
|
+
message: `Write Stryker's config and the class-name ignorer for ${stryker.length} project(s)?`,
|
|
233
|
+
default: true,
|
|
234
|
+
});
|
|
235
|
+
if (doScaffold) {
|
|
236
|
+
const installs = [];
|
|
237
|
+
const vitest = [];
|
|
238
|
+
for (const n of stryker) {
|
|
239
|
+
const r = await scaffoldStryker(CWD, n.rel);
|
|
240
|
+
for (const f of r.wrote) console.log(` Wrote: ${f}`);
|
|
241
|
+
for (const f of r.patched) console.log(` Added the ignorer to: ${f}`);
|
|
242
|
+
for (const f of r.kept) console.log(` ${chalk.dim(`Kept yours: ${f}`)}`);
|
|
243
|
+
for (const f of r.unreadable) {
|
|
244
|
+
console.log(` ${chalk.yellow("!")} ${f} is not readable as JSON — left alone, add the ignorer by hand`);
|
|
245
|
+
}
|
|
246
|
+
// A project that already runs Stryker needs neither the install nor
|
|
247
|
+
// the vitest exclude repeated at it; it only lacked the ignorer.
|
|
248
|
+
if (!n.ignorerOnly) {
|
|
249
|
+
installs.push(r.install);
|
|
250
|
+
vitest.push(r.vitest);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Left to the reader on purpose: installing touches the lockfile and
|
|
254
|
+
// the network, and editing an existing vitest config means parsing
|
|
255
|
+
// someone's plugins and aliases, where being wrong breaks their tests.
|
|
256
|
+
if (installs.length > 0) {
|
|
257
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("Two steps left, per project:")}`);
|
|
258
|
+
for (const c of installs) console.log(` ${chalk.cyan(c)}`);
|
|
259
|
+
for (const v of vitest) console.log(` ${chalk.dim(v)}`);
|
|
260
|
+
}
|
|
261
|
+
// The ignorer imports @stryker-mutator/api, which npm hoists by
|
|
262
|
+
// accident and pnpm does not. Missing, it prints one WARN PluginLoader
|
|
263
|
+
// line, loads nothing, and every className mutant comes back survived.
|
|
264
|
+
if (stryker.some((n) => n.ignorerOnly)) {
|
|
265
|
+
console.log(`\n ${chalk.yellow("!")} ${chalk.bold("The ignorer needs its own dependency:")}`);
|
|
266
|
+
console.log(` ${chalk.cyan("add @stryker-mutator/api as a dev dependency")} ${chalk.dim("— pnpm does not hoist it, and without it the ignorer silently does not load")}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
218
270
|
}
|
|
219
271
|
}
|
|
220
272
|
|
package/lib/local.mjs
CHANGED
|
@@ -161,6 +161,7 @@ export async function reportToolNeeds(projectDir) {
|
|
|
161
161
|
const at = rel ? rel + "/" : "";
|
|
162
162
|
needs.push({
|
|
163
163
|
label,
|
|
164
|
+
rel,
|
|
164
165
|
tool: "Stryker",
|
|
165
166
|
install: `npm --prefix ${rel || "."} i -D @stryker-mutator/core @stryker-mutator/vitest-runner`,
|
|
166
167
|
config: `${at}stryker.config.json {"testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"perTest"}`,
|
|
@@ -173,6 +174,20 @@ export async function reportToolNeeds(projectDir) {
|
|
|
173
174
|
`${at}.gitignore .stryker-tmp/`,
|
|
174
175
|
],
|
|
175
176
|
});
|
|
177
|
+
} else {
|
|
178
|
+
// Stryker is installed, so this project is not missing a tool — but the
|
|
179
|
+
// projects that already run it are the ones grinding through mutants on
|
|
180
|
+
// className strings, and a setup done before the ignorer existed has no
|
|
181
|
+
// way to learn about it. Offer it here or it never reaches them.
|
|
182
|
+
let cfg = "";
|
|
183
|
+
try {
|
|
184
|
+
cfg = await readFile(join(dir, "stryker.config.json"), "utf-8");
|
|
185
|
+
} catch {
|
|
186
|
+
// no config of its own — the scaffolder writes one
|
|
187
|
+
}
|
|
188
|
+
if (!cfg.includes("tailwind-classnames")) {
|
|
189
|
+
needs.push({ label, rel, tool: "Stryker", ignorerOnly: true });
|
|
190
|
+
}
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
if (pyp && !seen.has(label + ":py")) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Stryker setup, written rather than printed.
|
|
2
|
+
//
|
|
3
|
+
// The CLI used to print five steps and leave them to the reader. Every project
|
|
4
|
+
// then started with the same 40% of a first React run being class-name noise —
|
|
5
|
+
// mutants on `className` strings and `cn()` ternaries that cannot be killed
|
|
6
|
+
// without asserting on the stylesheet. Un-closeable findings are what teach
|
|
7
|
+
// people to --force, so the ignorer is not an optional extra; it is the
|
|
8
|
+
// difference between a gate that gets used and one that gets bypassed.
|
|
9
|
+
//
|
|
10
|
+
// Two things stay printed, deliberately. Installing the packages touches the
|
|
11
|
+
// lockfile and the network. Editing an existing vitest config means parsing
|
|
12
|
+
// someone's plugins, aliases and projects array, and getting it wrong breaks
|
|
13
|
+
// their test run rather than ours.
|
|
14
|
+
import { readFile, writeFile, access } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const CONFIG = {
|
|
18
|
+
$schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
|
19
|
+
testRunner: "vitest",
|
|
20
|
+
plugins: ["@stryker-mutator/vitest-runner", "./stryker-classname-ignorer.mjs"],
|
|
21
|
+
ignorers: ["tailwind-classnames"],
|
|
22
|
+
coverageAnalysis: "perTest",
|
|
23
|
+
tempDirName: ".stryker-tmp",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// .mjs, not .js: an app without "type": "module" in its package.json cannot
|
|
27
|
+
// load ESM from a .js file, and the plugin is ESM.
|
|
28
|
+
const IGNORER = `// Ignore by WHERE the mutant sits, not by what kind it is. Disabling the
|
|
29
|
+
// StringLiteral mutator globally would also stop mutating labels, keys and
|
|
30
|
+
// separators — strings whose value is behaviour.
|
|
31
|
+
import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
|
|
32
|
+
|
|
33
|
+
export const strykerPlugins = [
|
|
34
|
+
declareValuePlugin(PluginKind.Ignore, "tailwind-classnames", {
|
|
35
|
+
shouldIgnore(path) {
|
|
36
|
+
if (path.find((p) => p.isJSXAttribute() && p.node.name.name === "className"))
|
|
37
|
+
return "styling, not behaviour";
|
|
38
|
+
if (path.find((p) => p.isCallExpression() && p.node.callee.name === "cn"))
|
|
39
|
+
return "styling, not behaviour";
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
async function exists(p) {
|
|
46
|
+
try {
|
|
47
|
+
await access(p);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The ignorer imports @stryker-mutator/api, but nothing depends on it directly:
|
|
55
|
+
// npm's flat node_modules hoists it so the import resolves by accident, and
|
|
56
|
+
// pnpm's isolated store does not. The failure is one WARN PluginLoader line and
|
|
57
|
+
// an ignorer that never loads, so every class-name mutant comes back survived.
|
|
58
|
+
async function installCommand(dir, rel) {
|
|
59
|
+
const pnpm = await exists(join(dir, "pnpm-lock.yaml"));
|
|
60
|
+
const pkgs = "@stryker-mutator/core @stryker-mutator/vitest-runner @stryker-mutator/api";
|
|
61
|
+
return pnpm
|
|
62
|
+
? `pnpm --dir ${rel || "."} add -D ${pkgs}`
|
|
63
|
+
: `npm --prefix ${rel || "."} i -D ${pkgs}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Writes only what is absent. A project that already configured Stryker its own
|
|
67
|
+
// way keeps it — reporting the skip is the point, so nobody thinks a file they
|
|
68
|
+
// are looking at came from here.
|
|
69
|
+
export async function scaffoldStryker(projectDir, rel) {
|
|
70
|
+
const dir = rel ? join(projectDir, rel) : projectDir;
|
|
71
|
+
const at = rel ? `${rel}/` : "";
|
|
72
|
+
const wrote = [];
|
|
73
|
+
const kept = [];
|
|
74
|
+
const patched = [];
|
|
75
|
+
const unreadable = [];
|
|
76
|
+
|
|
77
|
+
// A project that already runs Stryker keeps every setting it chose. The two
|
|
78
|
+
// entries that wire the ignorer in are added, because a config written before
|
|
79
|
+
// the ignorer existed has no other way to learn about it — and that project is
|
|
80
|
+
// the one grinding through className mutants. A config this cannot parse is
|
|
81
|
+
// reported and left alone rather than rewritten from a guess.
|
|
82
|
+
const configPath = join(dir, "stryker.config.json");
|
|
83
|
+
if (await exists(configPath)) {
|
|
84
|
+
let cfg = null;
|
|
85
|
+
try {
|
|
86
|
+
cfg = JSON.parse(await readFile(configPath, "utf-8"));
|
|
87
|
+
} catch {
|
|
88
|
+
cfg = null;
|
|
89
|
+
}
|
|
90
|
+
if (cfg === null) {
|
|
91
|
+
unreadable.push(`${at}stryker.config.json`);
|
|
92
|
+
} else if ((cfg.ignorers ?? []).includes("tailwind-classnames")) {
|
|
93
|
+
kept.push(`${at}stryker.config.json`);
|
|
94
|
+
} else {
|
|
95
|
+
cfg.plugins = [...new Set([...(cfg.plugins ?? []), "./stryker-classname-ignorer.mjs"])];
|
|
96
|
+
cfg.ignorers = [...new Set([...(cfg.ignorers ?? []), "tailwind-classnames"])];
|
|
97
|
+
await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
98
|
+
patched.push(`${at}stryker.config.json`);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
await writeFile(configPath, JSON.stringify(CONFIG, null, 2) + "\n");
|
|
102
|
+
wrote.push(`${at}stryker.config.json`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ignorerPath = join(dir, "stryker-classname-ignorer.mjs");
|
|
106
|
+
if (await exists(ignorerPath)) {
|
|
107
|
+
kept.push(`${at}stryker-classname-ignorer.mjs`);
|
|
108
|
+
} else {
|
|
109
|
+
await writeFile(ignorerPath, IGNORER);
|
|
110
|
+
wrote.push(`${at}stryker-classname-ignorer.mjs`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A crashed run leaves a full second copy of the project behind, so this one
|
|
114
|
+
// matters even though it is one line.
|
|
115
|
+
const gitignorePath = join(dir, ".gitignore");
|
|
116
|
+
let gitignore = "";
|
|
117
|
+
try {
|
|
118
|
+
gitignore = await readFile(gitignorePath, "utf-8");
|
|
119
|
+
} catch {
|
|
120
|
+
// no .gitignore yet — one gets written below
|
|
121
|
+
}
|
|
122
|
+
if (/^\.stryker-tmp\/?$/m.test(gitignore)) {
|
|
123
|
+
kept.push(`${at}.gitignore`);
|
|
124
|
+
} else {
|
|
125
|
+
const sep = gitignore === "" || gitignore.endsWith("\n") ? "" : "\n";
|
|
126
|
+
await writeFile(gitignorePath, `${gitignore}${sep}.stryker-tmp/\n`);
|
|
127
|
+
wrote.push(`${at}.gitignore`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
wrote,
|
|
132
|
+
kept,
|
|
133
|
+
patched,
|
|
134
|
+
unreadable,
|
|
135
|
+
install: await installCommand(dir, rel),
|
|
136
|
+
// Stryker copies the whole project into .stryker-tmp/sandbox-*/, so every
|
|
137
|
+
// test file briefly exists twice and the runner collects both. They pass,
|
|
138
|
+
// so a doubled test count is the only symptom. Spread configDefaults.exclude
|
|
139
|
+
// rather than assigning it, or node_modules stops being excluded.
|
|
140
|
+
vitest: `${at}vitest.config.ts exclude: [...configDefaults.exclude, "**/.stryker-tmp/**"]`,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -226,13 +226,32 @@ Before this Write/Edit, you must:
|
|
|
226
226
|
|
|
227
227
|
1. For each skill listed above, say whether it applies to the file you're about to
|
|
228
228
|
write. If it does not — a Python skill and a TypeScript file, say — write
|
|
229
|
-
"does not apply" and move on. Do not invent a connection.
|
|
229
|
+
\\"does not apply\\" and move on. Do not invent a connection.
|
|
230
230
|
2. For the ones that do apply: state the specific rules that bear on this file, and
|
|
231
231
|
how your next write respects each.
|
|
232
232
|
3. Then ack all of them in a single Bash tool call:
|
|
233
233
|
$ACK_CMD
|
|
234
234
|
|
|
235
235
|
One ack per skill per session. After acking, retry the Write/Edit.
|
|
236
|
+
|
|
237
|
+
If that Bash call is denied by the auto mode classifier, STOP. Do not ask your
|
|
238
|
+
user to run it for you, and do not look for another route to create the files.
|
|
239
|
+
Both are what the classifier is there to stop, and asking a human to run a
|
|
240
|
+
command you were just denied makes them the tool that performs it. Report this
|
|
241
|
+
to them instead and let them decide:
|
|
242
|
+
|
|
243
|
+
Auto mode's built-in Auto-Mode Bypass rule counts a hook's ack file as working
|
|
244
|
+
around the permission system. It is a soft rule, so an allow entry clears it.
|
|
245
|
+
It has to go in ~/.claude/settings.json — the classifier does not read a
|
|
246
|
+
project's .claude/settings.json — or in /permissions, Auto mode tab. Keep the
|
|
247
|
+
\\\$defaults line or every built-in allow rule is discarded:
|
|
248
|
+
|
|
249
|
+
\\"autoMode\\": { \\"allow\\": [ \\"\\\$defaults\\",
|
|
250
|
+
\\"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-*
|
|
251
|
+
and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for
|
|
252
|
+
the user's own PreToolUse skill gates. Creating one satisfies a workflow gate
|
|
253
|
+
the user installed, grants no permission and executes no code, and is a false
|
|
254
|
+
positive for Auto-Mode Bypass.\\" ] }
|
|
236
255
|
$RULES_BLOCKS"
|
|
237
256
|
|
|
238
257
|
json_escape() {
|
|
@@ -1365,6 +1384,26 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
1365
1384
|
settings.hooks.Stop = [gauntletEntry];
|
|
1366
1385
|
}
|
|
1367
1386
|
|
|
1387
|
+
// Both gates clear by touching a marker under /tmp. The hook scripts let those
|
|
1388
|
+
// commands through themselves, but auto mode's permission classifier does not:
|
|
1389
|
+
// a touch whose only purpose is to unlock a gate is exactly what it refuses.
|
|
1390
|
+
// So in auto mode the application gate could not be satisfied at all, and the
|
|
1391
|
+
// model was reduced to asking its user to run the touch by hand — which is
|
|
1392
|
+
// indistinguishable from the laundering the classifier exists to stop.
|
|
1393
|
+
// Allowlisting the three marker paths is what makes the gates workable there.
|
|
1394
|
+
// Only these literal prefixes are granted, and an existing entry is not
|
|
1395
|
+
// duplicated on reinstall.
|
|
1396
|
+
const MARKER_ALLOW = [
|
|
1397
|
+
"Bash(touch /tmp/claude-skill-gate-*)",
|
|
1398
|
+
"Bash(touch /tmp/claude-skill-acked-*)",
|
|
1399
|
+
"Bash(touch /tmp/claude-skill-loaded-*)",
|
|
1400
|
+
];
|
|
1401
|
+
if (!settings.permissions) settings.permissions = {};
|
|
1402
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
1403
|
+
for (const rule of MARKER_ALLOW) {
|
|
1404
|
+
if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1368
1407
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
1369
1408
|
|
|
1370
1409
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|