claude-crap 0.1.2

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.
Files changed (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. package/tsconfig.json +27 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Unit tests for the project score engine.
3
+ *
4
+ * Builds in-memory `SarifStore` instances with hand-crafted finding
5
+ * sets so we can verify each dimension's letter-grade boundaries and
6
+ * the overall worst-of aggregation in isolation, with no filesystem.
7
+ *
8
+ * @module tests/score.test
9
+ */
10
+
11
+ import { describe, it, before, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { SarifStore, type PersistedSarif } from "../sarif/sarif-store.js";
18
+ import {
19
+ computeProjectScore,
20
+ renderProjectScoreMarkdown,
21
+ type ProjectScore,
22
+ } from "../metrics/score.js";
23
+
24
+ /**
25
+ * Build a minimal SARIF doc with one finding. The `ruleId`, `level`,
26
+ * and `effortMinutes` parameters drive how the score engine classifies
27
+ * the finding (security vs reliability, severity, TDR contribution).
28
+ */
29
+ function makeSarif(opts: {
30
+ ruleId: string;
31
+ uri?: string;
32
+ line?: number;
33
+ column?: number;
34
+ level?: "error" | "warning" | "note";
35
+ effortMinutes?: number;
36
+ sourceTool?: string;
37
+ }): PersistedSarif {
38
+ return {
39
+ version: "2.1.0",
40
+ runs: [
41
+ {
42
+ tool: { driver: { name: opts.sourceTool ?? "test", version: "0" } },
43
+ results: [
44
+ {
45
+ ruleId: opts.ruleId,
46
+ level: opts.level ?? "warning",
47
+ message: { text: opts.ruleId },
48
+ locations: [
49
+ {
50
+ physicalLocation: {
51
+ artifactLocation: { uri: opts.uri ?? "src/foo.ts" },
52
+ region: { startLine: opts.line ?? 1, startColumn: opts.column ?? 1 },
53
+ },
54
+ },
55
+ ],
56
+ properties: { effortMinutes: opts.effortMinutes ?? 0 },
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Construct a fresh in-memory SarifStore in a temp directory and ingest
66
+ * a list of findings. Each doc carries its own `tool.driver.name`, and
67
+ * we pass that exact name to `ingestRun()` so the per-tool aggregation
68
+ * tests can distinguish between scanners.
69
+ */
70
+ async function buildStore(workspace: string, docs: PersistedSarif[]): Promise<SarifStore> {
71
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports" });
72
+ await store.loadLatest();
73
+ for (const doc of docs) {
74
+ const sourceTool = doc.runs[0]?.tool?.driver?.name ?? "test-tool";
75
+ store.ingestRun(doc, sourceTool);
76
+ }
77
+ return store;
78
+ }
79
+
80
+ /**
81
+ * Helper that runs the score engine with a minimal sane config.
82
+ */
83
+ function score(store: SarifStore, workspaceRoot: string, loc = 1000, files = 10): ProjectScore {
84
+ return computeProjectScore({
85
+ workspaceRoot,
86
+ minutesPerLoc: 30,
87
+ tdrMaxRating: "C",
88
+ workspace: { physicalLoc: loc, fileCount: files },
89
+ sarifStore: store,
90
+ dashboardUrl: "http://127.0.0.1:5117",
91
+ sarifReportPath: store.consolidatedReportPath,
92
+ });
93
+ }
94
+
95
+ describe("computeProjectScore", () => {
96
+ let workspace = "";
97
+
98
+ before(async () => {
99
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-score-"));
100
+ });
101
+
102
+ after(async () => {
103
+ if (workspace) await rm(workspace, { recursive: true, force: true });
104
+ });
105
+
106
+ it("rates an empty project as A across the board", async () => {
107
+ const store = await buildStore(workspace, []);
108
+ const s = score(store, workspace);
109
+ assert.equal(s.maintainability.rating, "A");
110
+ assert.equal(s.reliability.rating, "A");
111
+ assert.equal(s.security.rating, "A");
112
+ assert.equal(s.overall.rating, "A");
113
+ assert.equal(s.overall.passes, true);
114
+ });
115
+
116
+ it("classifies a SQL injection rule as security", async () => {
117
+ const store = await buildStore(workspace, [
118
+ makeSarif({ ruleId: "python.lang.sql-injection", level: "error" }),
119
+ ]);
120
+ const s = score(store, workspace);
121
+ assert.equal(s.security.errorFindings, 1);
122
+ assert.equal(s.reliability.errorFindings, 0);
123
+ assert.equal(s.security.rating, "D"); // 1 error → D
124
+ });
125
+
126
+ it("classifies a non-security rule as reliability", async () => {
127
+ const store = await buildStore(workspace, [
128
+ makeSarif({ ruleId: "ts.unused-variable", level: "warning" }),
129
+ ]);
130
+ const s = score(store, workspace);
131
+ assert.equal(s.reliability.warningFindings, 1);
132
+ assert.equal(s.security.warningFindings, 0);
133
+ assert.equal(s.reliability.rating, "C"); // 1 warning → C
134
+ });
135
+
136
+ it("escalates reliability to E with 3+ errors", async () => {
137
+ const store = await buildStore(workspace, [
138
+ makeSarif({ ruleId: "rule.a", level: "error", line: 1 }),
139
+ makeSarif({ ruleId: "rule.b", level: "error", line: 2 }),
140
+ makeSarif({ ruleId: "rule.c", level: "error", line: 3 }),
141
+ ]);
142
+ const s = score(store, workspace);
143
+ assert.equal(s.reliability.rating, "E");
144
+ });
145
+
146
+ it("collapses overall to the worst dimension", async () => {
147
+ const store = await buildStore(workspace, [
148
+ // 1 security error → security D, reliability A, maintainability A → overall D
149
+ makeSarif({ ruleId: "auth.broken", level: "error" }),
150
+ ]);
151
+ const s = score(store, workspace);
152
+ assert.equal(s.security.rating, "D");
153
+ assert.equal(s.reliability.rating, "A");
154
+ assert.equal(s.maintainability.rating, "A");
155
+ assert.equal(s.overall.rating, "D");
156
+ });
157
+
158
+ it("marks overall as failing when worse than the policy ceiling", async () => {
159
+ const store = await buildStore(workspace, [
160
+ makeSarif({ ruleId: "auth.broken", level: "error" }),
161
+ ]);
162
+ const s = score(store, workspace);
163
+ // Overall = D, policy ceiling = C → fails
164
+ assert.equal(s.overall.passes, false);
165
+ });
166
+
167
+ it("marks overall as passing when within policy", async () => {
168
+ const store = await buildStore(workspace, [
169
+ makeSarif({ ruleId: "ts.style", level: "warning" }),
170
+ ]);
171
+ const s = score(store, workspace);
172
+ // Reliability = C, ceiling = C → equal, not worse, so passes
173
+ assert.equal(s.overall.rating, "C");
174
+ assert.equal(s.overall.passes, true);
175
+ });
176
+
177
+ it("derives maintainability rating from TDR boundaries", async () => {
178
+ // 360 minutes of remediation over 1000 LOC × 30 min/LOC = 30000 cost
179
+ // → TDR = 360 / 30000 = 1.2% → A
180
+ const store = await buildStore(workspace, [
181
+ makeSarif({ ruleId: "ts.todo", level: "note", effortMinutes: 360 }),
182
+ ]);
183
+ const s = score(store, workspace);
184
+ assert.ok(s.maintainability.tdrPercent < 5);
185
+ assert.equal(s.maintainability.rating, "A");
186
+
187
+ // 6000 minutes / 30000 = 20% → C
188
+ const store2 = await buildStore(workspace, [
189
+ makeSarif({ ruleId: "ts.todo2", level: "note", effortMinutes: 6000 }),
190
+ ]);
191
+ const s2 = score(store2, workspace);
192
+ assert.equal(s2.maintainability.rating, "C");
193
+
194
+ // 18000 minutes / 30000 = 60% → E
195
+ const store3 = await buildStore(workspace, [
196
+ makeSarif({ ruleId: "ts.todo3", level: "note", effortMinutes: 18000 }),
197
+ ]);
198
+ const s3 = score(store3, workspace);
199
+ assert.equal(s3.maintainability.rating, "E");
200
+ });
201
+
202
+ it("aggregates findings by tool and by file", async () => {
203
+ const store = await buildStore(workspace, [
204
+ makeSarif({ ruleId: "rule.a", uri: "src/a.ts", line: 1, sourceTool: "semgrep" }),
205
+ makeSarif({ ruleId: "rule.b", uri: "src/a.ts", line: 2, sourceTool: "semgrep" }),
206
+ makeSarif({ ruleId: "rule.c", uri: "src/b.ts", line: 1, sourceTool: "eslint" }),
207
+ ]);
208
+ const s = score(store, workspace);
209
+ assert.equal(s.findings.byTool.semgrep, 2);
210
+ assert.equal(s.findings.byTool.eslint, 1);
211
+ assert.equal(s.findings.byFile["src/a.ts"], 2);
212
+ assert.equal(s.findings.byFile["src/b.ts"], 1);
213
+ });
214
+
215
+ it("propagates the dashboard URL into the location block", async () => {
216
+ const store = await buildStore(workspace, []);
217
+ const s = score(store, workspace);
218
+ assert.equal(s.location.dashboardUrl, "http://127.0.0.1:5117");
219
+ assert.ok(s.location.sarifReportPath.endsWith("latest.sarif"));
220
+ });
221
+ });
222
+
223
+ describe("renderProjectScoreMarkdown", () => {
224
+ let workspace = "";
225
+
226
+ before(async () => {
227
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-score-md-"));
228
+ });
229
+
230
+ after(async () => {
231
+ if (workspace) await rm(workspace, { recursive: true, force: true });
232
+ });
233
+
234
+ it("renders a compact summary that includes the overall rating and dashboard URL", async () => {
235
+ const store = await buildStore(workspace, [
236
+ makeSarif({ ruleId: "ts.style", level: "warning" }),
237
+ ]);
238
+ const s = score(store, workspace);
239
+ const md = renderProjectScoreMarkdown(s);
240
+ assert.match(md, /## claude-crap :: project score/);
241
+ assert.match(md, /\*\*Overall: C\*\*/);
242
+ assert.match(md, /Dashboard:.*127\.0\.0\.1:5117/);
243
+ assert.match(md, /Report:.*latest\.sarif/);
244
+ });
245
+
246
+ it("renders a fallback line when no dashboard URL is configured", async () => {
247
+ const store = await buildStore(workspace, []);
248
+ const s = computeProjectScore({
249
+ workspaceRoot: workspace,
250
+ minutesPerLoc: 30,
251
+ tdrMaxRating: "C",
252
+ workspace: { physicalLoc: 100, fileCount: 1 },
253
+ sarifStore: store,
254
+ dashboardUrl: null,
255
+ sarifReportPath: store.consolidatedReportPath,
256
+ });
257
+ const md = renderProjectScoreMarkdown(s);
258
+ assert.match(md, /not running/);
259
+ });
260
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Frontmatter contract for claude-crap's shipped skills.
3
+ *
4
+ * Every directory under `skills/` at the plugin root must contain a
5
+ * SKILL.md file with YAML frontmatter declaring at minimum `name`
6
+ * and `description`. The `name` has to match the directory name so
7
+ * Claude Code's slash-command namespace (`/claude-crap:<name>`)
8
+ * resolves cleanly. The `description` drives model-invocation
9
+ * triggering — the skill-creator skill's guidance is emphatic that
10
+ * undertriggering is the common failure mode, so descriptions need
11
+ * "pushy" language like "use this skill whenever..." to bias Claude
12
+ * toward invoking them when context matches.
13
+ *
14
+ * These tests pin the shape of every SKILL.md the plugin ships so
15
+ * a future drive-by edit that removes the `description` field or
16
+ * renames a directory without updating the frontmatter cannot slip
17
+ * through CI silently. They also pin the minimum substantive length
18
+ * of descriptions (>100 chars) so terse one-liners like
19
+ * "run a command" do not count as valid skills.
20
+ *
21
+ * @module tests/skills-frontmatter.test
22
+ */
23
+
24
+ import { describe, it } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { readFile, readdir } from "node:fs/promises";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const SKILLS_DIR = resolve(HERE, "..", "..", "plugin", "skills");
32
+
33
+ /**
34
+ * Minimal YAML frontmatter parser covering the subset claude-crap
35
+ * actually uses: top-level string scalars only, no nested objects,
36
+ * no arrays, no quoting edge cases. The full-fat YAML parsers in
37
+ * the ecosystem would add 100+ KB of dependencies for something we
38
+ * can solve in 20 lines; this stays self-contained.
39
+ *
40
+ * @param content Raw file contents of a SKILL.md file.
41
+ * @returns Parsed frontmatter fields, or `null` if the file
42
+ * does not start with a `---` delimited block.
43
+ */
44
+ function parseFrontmatter(
45
+ content: string,
46
+ ): { name?: string; description?: string; body: string } | null {
47
+ const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
48
+ if (!match) return null;
49
+ const [, yaml, body] = match as unknown as [string, string, string];
50
+ const result: { name?: string; description?: string; body: string } = { body };
51
+ for (const rawLine of yaml.split(/\r?\n/)) {
52
+ const line = rawLine.trim();
53
+ if (!line || line.startsWith("#")) continue;
54
+ const kv = line.match(/^([a-zA-Z_-]+)\s*:\s*(.+)$/);
55
+ if (!kv) continue;
56
+ const [, key, value] = kv as unknown as [string, string, string];
57
+ const clean = value.replace(/^['"]|['"]$/g, "");
58
+ if (key === "name") result.name = clean;
59
+ if (key === "description") result.description = clean;
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Return every immediate subdirectory of `skills/`. If the root
66
+ * itself does not exist, returns an empty list so the test suite
67
+ * can still run on a fresh clone before any skills have been added.
68
+ */
69
+ async function listSkillDirs(): Promise<string[]> {
70
+ let entries;
71
+ try {
72
+ entries = await readdir(SKILLS_DIR, { withFileTypes: true });
73
+ } catch (err) {
74
+ const error = err as NodeJS.ErrnoException;
75
+ if (error.code === "ENOENT") return [];
76
+ throw error;
77
+ }
78
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
79
+ }
80
+
81
+ describe("claude-crap skills — frontmatter contract", () => {
82
+ it("ships at least one user-invocable skill", async () => {
83
+ const dirs = await listSkillDirs();
84
+ assert.ok(
85
+ dirs.length > 0,
86
+ "expected at least one skill directory under skills/ — v0.1.1 introduces /claude-crap:score, /claude-crap:check-test, /claude-crap:analyze, /claude-crap:adopt",
87
+ );
88
+ });
89
+
90
+ it("every skills/<name>/ directory contains a SKILL.md file", async () => {
91
+ const dirs = await listSkillDirs();
92
+ for (const dir of dirs) {
93
+ const skillMd = join(SKILLS_DIR, dir, "SKILL.md");
94
+ const content = await readFile(skillMd, "utf8").catch(() => null);
95
+ assert.ok(content, `skills/${dir}/SKILL.md is missing`);
96
+ }
97
+ });
98
+
99
+ it("every SKILL.md starts with a --- delimited YAML frontmatter block", async () => {
100
+ const dirs = await listSkillDirs();
101
+ for (const dir of dirs) {
102
+ const content = await readFile(join(SKILLS_DIR, dir, "SKILL.md"), "utf8");
103
+ const fm = parseFrontmatter(content);
104
+ assert.ok(
105
+ fm,
106
+ `skills/${dir}/SKILL.md must start with '---' ... '---' YAML frontmatter`,
107
+ );
108
+ }
109
+ });
110
+
111
+ it("every frontmatter declares a non-empty name matching the directory", async () => {
112
+ const dirs = await listSkillDirs();
113
+ for (const dir of dirs) {
114
+ const content = await readFile(join(SKILLS_DIR, dir, "SKILL.md"), "utf8");
115
+ const fm = parseFrontmatter(content);
116
+ assert.ok(fm?.name, `skills/${dir}/SKILL.md frontmatter must have a 'name' field`);
117
+ assert.equal(
118
+ fm.name,
119
+ dir,
120
+ `skills/${dir}/SKILL.md frontmatter name '${fm.name}' must match the directory basename '${dir}'`,
121
+ );
122
+ }
123
+ });
124
+
125
+ it("every frontmatter declares a substantive description (>100 chars)", async () => {
126
+ const dirs = await listSkillDirs();
127
+ for (const dir of dirs) {
128
+ const content = await readFile(join(SKILLS_DIR, dir, "SKILL.md"), "utf8");
129
+ const fm = parseFrontmatter(content);
130
+ assert.ok(
131
+ fm?.description,
132
+ `skills/${dir}/SKILL.md frontmatter must have a 'description' field`,
133
+ );
134
+ assert.ok(
135
+ fm.description.length > 100,
136
+ `skills/${dir}/SKILL.md description is only ${fm.description.length} chars; Claude Code's trigger matcher needs substantive context to invoke the skill. Rewrite it with 'use this skill whenever ...' phrasing and at least one example context.`,
137
+ );
138
+ }
139
+ });
140
+
141
+ it("every description uses 'use this skill when/whenever' trigger language", async () => {
142
+ // Rationale: the skill-creator guidance flags undertriggering as the
143
+ // common failure mode and recommends 'pushy' descriptions that bias
144
+ // Claude toward invoking the skill. Enforcing a minimal version of
145
+ // that discipline at the lint level means every future skill has to
146
+ // at least consider when it should trigger before merging.
147
+ const dirs = await listSkillDirs();
148
+ for (const dir of dirs) {
149
+ const content = await readFile(join(SKILLS_DIR, dir, "SKILL.md"), "utf8");
150
+ const fm = parseFrontmatter(content);
151
+ assert.ok(fm?.description);
152
+ assert.match(
153
+ fm.description,
154
+ /use this skill (when|whenever)/i,
155
+ `skills/${dir}/SKILL.md description should include 'use this skill when/whenever ...' trigger language (see the skill-creator guidance on combating undertriggering)`,
156
+ );
157
+ }
158
+ });
159
+
160
+ it("every SKILL.md body is non-empty", async () => {
161
+ const dirs = await listSkillDirs();
162
+ for (const dir of dirs) {
163
+ const content = await readFile(join(SKILLS_DIR, dir, "SKILL.md"), "utf8");
164
+ const fm = parseFrontmatter(content);
165
+ assert.ok(fm, `${dir}/SKILL.md must parse`);
166
+ assert.ok(
167
+ fm.body.trim().length > 0,
168
+ `skills/${dir}/SKILL.md must have instructions after the frontmatter`,
169
+ );
170
+ }
171
+ });
172
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * End-to-end tests for the Stop quality-gate hook under each of the
3
+ * three supported strictness modes.
4
+ *
5
+ * The test spawns `hooks/stop-quality-gate.mjs` as a subprocess with
6
+ * a hand-crafted fixture workspace containing:
7
+ *
8
+ * - `.claude-crap/reports/latest.sarif` — one error-level finding
9
+ * so the gate has a reason to fail.
10
+ * - (optional) `.claude-crap.json` — exercises file-based config.
11
+ * - One small `.ts` file so the workspace walker returns a non-zero
12
+ * LOC denominator and TDR math does not divide by one.
13
+ *
14
+ * The test then asserts on the subprocess exit code and where the
15
+ * verdict was written (stdout vs stderr) for each mode, matching the
16
+ * design in the CHANGELOG:
17
+ *
18
+ * - `strict` — exit 2, verdict on stderr (hard block)
19
+ * - `warn` — exit 0, verdict on stdout (soft nudge, agent sees it)
20
+ * - `advisory` — exit 0, one-liner on stdout (minimal pressure)
21
+ *
22
+ * These tests require `dist/` to be built because the Stop hook
23
+ * imports `dist/metrics/tdr.js` at runtime. The suite skips cleanly
24
+ * on fresh checkouts before the first build.
25
+ *
26
+ * @module tests/stop-quality-gate-strictness.test
27
+ */
28
+
29
+ import { describe, it, before, after } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { spawn } from "node:child_process";
32
+ import { promises as fs, statSync } from "node:fs";
33
+ import { mkdtemp, rm } from "node:fs/promises";
34
+ import { tmpdir } from "node:os";
35
+ import { dirname, join, resolve } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ const PLUGIN_ROOT = resolve(HERE, "..", "..");
40
+ const HOOK_PATH = join(PLUGIN_ROOT, "plugin", "hooks", "stop-quality-gate.mjs");
41
+ const TDR_ENTRY = process.env.SONAR_TDR_ENTRY
42
+ ? resolve(process.env.SONAR_TDR_ENTRY)
43
+ : join(PLUGIN_ROOT, "plugin", "bundle", "tdr-engine.mjs");
44
+
45
+ let bundleBuilt = false;
46
+ try {
47
+ statSync(TDR_ENTRY);
48
+ bundleBuilt = true;
49
+ } catch {
50
+ bundleBuilt = false;
51
+ }
52
+
53
+ interface HookResult {
54
+ readonly code: number;
55
+ readonly stdout: string;
56
+ readonly stderr: string;
57
+ }
58
+
59
+ /**
60
+ * Spawn the Stop hook against a fixture workspace, passing an empty
61
+ * JSON payload on stdin (the Stop hook does not depend on the hook
62
+ * input — the verdict is entirely a function of the on-disk SARIF
63
+ * plus the workspace LOC walk).
64
+ */
65
+ function runStopHook(
66
+ workspace: string,
67
+ envOverrides: Record<string, string | undefined> = {},
68
+ ): Promise<HookResult> {
69
+ return new Promise((resolvePromise, reject) => {
70
+ const env: Record<string, string> = {
71
+ PATH: process.env.PATH ?? "",
72
+ NODE_ENV: "test",
73
+ CLAUDE_PROJECT_DIR: workspace,
74
+ };
75
+ for (const [key, value] of Object.entries(envOverrides)) {
76
+ if (value === undefined) delete env[key];
77
+ else env[key] = value;
78
+ }
79
+ const child = spawn(process.execPath, [HOOK_PATH], {
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ cwd: workspace,
82
+ env,
83
+ });
84
+ let stdout = "";
85
+ let stderr = "";
86
+ child.stdout.on("data", (chunk) => {
87
+ stdout += chunk.toString();
88
+ });
89
+ child.stderr.on("data", (chunk) => {
90
+ stderr += chunk.toString();
91
+ });
92
+ child.on("error", reject);
93
+ child.on("exit", (code) => {
94
+ resolvePromise({ code: code ?? -1, stdout, stderr });
95
+ });
96
+ child.stdin.write("{}");
97
+ child.stdin.end();
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Build a fixture workspace with a failing SARIF report on disk and
103
+ * one minimal source file so the LOC walk returns a sensible
104
+ * denominator for the TDR computation.
105
+ */
106
+ async function createFailingFixture(): Promise<string> {
107
+ const workspace = await mkdtemp(join(tmpdir(), "claude-crap-strict-"));
108
+ const reportsDir = join(workspace, ".claude-crap", "reports");
109
+ await fs.mkdir(reportsDir, { recursive: true });
110
+
111
+ // One error-level finding guarantees the SONAR-GATE-ERRORS policy fails.
112
+ const sarif = {
113
+ version: "2.1.0",
114
+ runs: [
115
+ {
116
+ tool: { driver: { name: "claude-crap-fixture", version: "0.0.0" } },
117
+ results: [
118
+ {
119
+ ruleId: "FIXTURE-ERR-001",
120
+ level: "error",
121
+ message: { text: "fixture error so the gate has something to block on" },
122
+ locations: [
123
+ {
124
+ physicalLocation: {
125
+ artifactLocation: { uri: "src/a.ts" },
126
+ region: { startLine: 1, startColumn: 1 },
127
+ },
128
+ },
129
+ ],
130
+ properties: {
131
+ sourceTool: "claude-crap-fixture",
132
+ effortMinutes: 90,
133
+ },
134
+ },
135
+ ],
136
+ },
137
+ ],
138
+ };
139
+ await fs.writeFile(
140
+ join(reportsDir, "latest.sarif"),
141
+ JSON.stringify(sarif, null, 2),
142
+ "utf8",
143
+ );
144
+
145
+ // A non-zero physical LOC means TDR math is well-defined.
146
+ await fs.mkdir(join(workspace, "src"), { recursive: true });
147
+ await fs.writeFile(
148
+ join(workspace, "src", "a.ts"),
149
+ "export const answer = 42;\n",
150
+ "utf8",
151
+ );
152
+
153
+ return workspace;
154
+ }
155
+
156
+ describe(
157
+ "stop-quality-gate hook — strictness matrix",
158
+ { skip: !bundleBuilt },
159
+ () => {
160
+ let workspace = "";
161
+
162
+ before(async () => {
163
+ workspace = await createFailingFixture();
164
+ });
165
+
166
+ after(async () => {
167
+ if (workspace) await rm(workspace, { recursive: true, force: true });
168
+ });
169
+
170
+ it("default (no env, no file) → exit 2 + stderr box (strict is the default)", async () => {
171
+ const result = await runStopHook(workspace, {
172
+ CLAUDE_CRAP_STRICTNESS: undefined,
173
+ });
174
+ assert.equal(result.code, 2, `stderr was: ${result.stderr}`);
175
+ assert.match(result.stderr, /Stop quality gate BLOCKED/);
176
+ assert.match(result.stderr, /SONAR-GATE-ERRORS/);
177
+ });
178
+
179
+ it("CLAUDE_CRAP_STRICTNESS=strict → exit 2 + stderr box", async () => {
180
+ const result = await runStopHook(workspace, {
181
+ CLAUDE_CRAP_STRICTNESS: "strict",
182
+ });
183
+ assert.equal(result.code, 2);
184
+ assert.match(result.stderr, /Stop quality gate BLOCKED/);
185
+ });
186
+
187
+ it("CLAUDE_CRAP_STRICTNESS=warn → exit 0 + full verdict on stdout", async () => {
188
+ const result = await runStopHook(workspace, {
189
+ CLAUDE_CRAP_STRICTNESS: "warn",
190
+ });
191
+ assert.equal(result.code, 0, `stderr was: ${result.stderr}`);
192
+ // The full verdict must still reach the hook transcript so the
193
+ // agent can choose to remediate on its next turn.
194
+ assert.match(result.stdout, /Stop quality gate WARNING/);
195
+ assert.match(result.stdout, /SONAR-GATE-ERRORS/);
196
+ });
197
+
198
+ it("CLAUDE_CRAP_STRICTNESS=advisory → exit 0 + one-line summary on stdout", async () => {
199
+ const result = await runStopHook(workspace, {
200
+ CLAUDE_CRAP_STRICTNESS: "advisory",
201
+ });
202
+ assert.equal(result.code, 0, `stderr was: ${result.stderr}`);
203
+ assert.match(result.stdout, /Stop quality gate ADVISORY/);
204
+ // Advisory must NOT render the heavy multi-line verdict box —
205
+ // the point is minimal pressure. Walking the stdout for the
206
+ // "policy failure(s)" decorator from the blocking/warning box
207
+ // would be a robust negative assertion.
208
+ assert.doesNotMatch(result.stdout, /policy failure\(s\)/);
209
+ });
210
+
211
+ it(".claude-crap.json with strictness='warn' is honored when env is unset", async () => {
212
+ const configPath = join(workspace, ".claude-crap.json");
213
+ await fs.writeFile(configPath, JSON.stringify({ strictness: "warn" }), "utf8");
214
+ try {
215
+ const result = await runStopHook(workspace, {
216
+ CLAUDE_CRAP_STRICTNESS: undefined,
217
+ });
218
+ assert.equal(result.code, 0, `stderr was: ${result.stderr}`);
219
+ assert.match(result.stdout, /Stop quality gate WARNING/);
220
+ } finally {
221
+ await fs.unlink(configPath);
222
+ }
223
+ });
224
+
225
+ it("env variable wins over .claude-crap.json even when the file disagrees", async () => {
226
+ const configPath = join(workspace, ".claude-crap.json");
227
+ await fs.writeFile(
228
+ configPath,
229
+ JSON.stringify({ strictness: "advisory" }),
230
+ "utf8",
231
+ );
232
+ try {
233
+ const result = await runStopHook(workspace, {
234
+ CLAUDE_CRAP_STRICTNESS: "strict",
235
+ });
236
+ assert.equal(result.code, 2, `stderr was: ${result.stderr}`);
237
+ assert.match(result.stderr, /Stop quality gate BLOCKED/);
238
+ } finally {
239
+ await fs.unlink(configPath);
240
+ }
241
+ });
242
+ },
243
+ );