@spardutti/claude-skills 2.20.2 → 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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.20.2",
3
+ "version": "2.22.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "repository": {