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,138 @@
1
+ /**
2
+ * Unit tests for the ESLint adapter.
3
+ *
4
+ * ESLint's native JSON format (`eslint -f json`) is an array of file
5
+ * reports with a `messages[]` array inside each. The adapter flattens
6
+ * every message into a SARIF `result` and maps severity codes
7
+ * 0 / 1 / 2 to "note" / "warning" / "error".
8
+ *
9
+ * @module tests/adapters/eslint.test
10
+ */
11
+
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+
15
+ import { adaptEslint } from "../../adapters/eslint.js";
16
+
17
+ describe("adaptEslint", () => {
18
+ it("maps severity 2 to SARIF error", () => {
19
+ const raw = [
20
+ {
21
+ filePath: "/abs/src/a.js",
22
+ messages: [
23
+ {
24
+ ruleId: "no-undef",
25
+ severity: 2,
26
+ message: "'foo' is not defined.",
27
+ line: 10,
28
+ column: 5,
29
+ },
30
+ ],
31
+ },
32
+ ];
33
+ const result = adaptEslint(raw);
34
+ const sarifResult = result.document.runs[0]?.results?.[0] as { level?: string };
35
+ assert.equal(sarifResult?.level, "error");
36
+ assert.equal(result.sourceTool, "eslint");
37
+ assert.equal(result.findingCount, 1);
38
+ });
39
+
40
+ it("maps severity 1 to SARIF warning and 0 to note", () => {
41
+ const raw = [
42
+ {
43
+ filePath: "/abs/src/b.js",
44
+ messages: [
45
+ { ruleId: "no-unused-vars", severity: 1, message: "warn", line: 1, column: 1 },
46
+ { ruleId: "no-console", severity: 0, message: "off", line: 2, column: 1 },
47
+ ],
48
+ },
49
+ ];
50
+ const result = adaptEslint(raw);
51
+ const levels = (result.document.runs[0]?.results ?? [])
52
+ .map((r) => (r as { level?: string }).level)
53
+ .sort();
54
+ assert.deepEqual(levels, ["note", "warning"]);
55
+ });
56
+
57
+ it("propagates line, column, endLine, and endColumn", () => {
58
+ const raw = [
59
+ {
60
+ filePath: "/abs/src/c.js",
61
+ messages: [
62
+ {
63
+ ruleId: "prefer-const",
64
+ severity: 1,
65
+ message: "use const",
66
+ line: 42,
67
+ column: 9,
68
+ endLine: 42,
69
+ endColumn: 15,
70
+ },
71
+ ],
72
+ },
73
+ ];
74
+ const result = adaptEslint(raw);
75
+ const first = result.document.runs[0]?.results?.[0] as {
76
+ locations?: Array<{
77
+ physicalLocation?: {
78
+ region?: { startLine?: number; startColumn?: number; endLine?: number; endColumn?: number };
79
+ };
80
+ }>;
81
+ };
82
+ const region = first?.locations?.[0]?.physicalLocation?.region;
83
+ assert.equal(region?.startLine, 42);
84
+ assert.equal(region?.startColumn, 9);
85
+ assert.equal(region?.endLine, 42);
86
+ assert.equal(region?.endColumn, 15);
87
+ });
88
+
89
+ it("flattens multiple file reports into a single run", () => {
90
+ const raw = [
91
+ {
92
+ filePath: "/abs/src/x.js",
93
+ messages: [{ ruleId: "r1", severity: 2, message: "m1", line: 1, column: 1 }],
94
+ },
95
+ {
96
+ filePath: "/abs/src/y.js",
97
+ messages: [{ ruleId: "r2", severity: 1, message: "m2", line: 2, column: 2 }],
98
+ },
99
+ ];
100
+ const result = adaptEslint(raw);
101
+ assert.equal(result.findingCount, 2);
102
+ assert.equal(result.document.runs.length, 1);
103
+ });
104
+
105
+ it("accepts a JSON string", () => {
106
+ const raw = JSON.stringify([
107
+ { filePath: "/a.js", messages: [{ ruleId: "r", severity: 2, message: "m", line: 1, column: 1 }] },
108
+ ]);
109
+ const result = adaptEslint(raw);
110
+ assert.equal(result.findingCount, 1);
111
+ });
112
+
113
+ it("throws when the input is not an array of file reports", () => {
114
+ assert.throws(() => adaptEslint({ not: "an array" }));
115
+ });
116
+
117
+ it("ignores file reports with no filePath", () => {
118
+ const raw = [
119
+ { messages: [{ ruleId: "r", severity: 2, message: "m", line: 1, column: 1 }] },
120
+ {
121
+ filePath: "/a.js",
122
+ messages: [{ ruleId: "r2", severity: 1, message: "m2", line: 1, column: 1 }],
123
+ },
124
+ ];
125
+ const result = adaptEslint(raw);
126
+ assert.equal(result.findingCount, 1);
127
+ });
128
+
129
+ it("propagates error budgets proportional to severity", () => {
130
+ const errorOnly = adaptEslint([
131
+ { filePath: "/a.js", messages: [{ ruleId: "r", severity: 2, message: "m", line: 1, column: 1 }] },
132
+ ]);
133
+ const warnOnly = adaptEslint([
134
+ { filePath: "/a.js", messages: [{ ruleId: "r", severity: 1, message: "m", line: 1, column: 1 }] },
135
+ ]);
136
+ assert.ok(errorOnly.totalEffortMinutes > warnOnly.totalEffortMinutes);
137
+ });
138
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Unit tests for the Semgrep SARIF adapter.
3
+ *
4
+ * Semgrep emits SARIF 2.1.0 natively, so the adapter's responsibility
5
+ * is enrichment: add `effortMinutes` and `sourceTool` to every
6
+ * finding's `properties` bag, and normalize `tool.driver.name` to the
7
+ * canonical `"semgrep"` string.
8
+ *
9
+ * @module tests/adapters/semgrep.test
10
+ */
11
+
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+
15
+ import { adaptSemgrep } from "../../adapters/semgrep.js";
16
+ import type { PersistedSarif } from "../../sarif/sarif-store.js";
17
+
18
+ /**
19
+ * Build a minimal semgrep-style SARIF document with one finding.
20
+ */
21
+ function makeSemgrepSarif(opts: {
22
+ ruleId?: string;
23
+ level?: "error" | "warning" | "note";
24
+ line?: number;
25
+ column?: number;
26
+ }): PersistedSarif {
27
+ return {
28
+ version: "2.1.0",
29
+ runs: [
30
+ {
31
+ tool: { driver: { name: "Semgrep OSS", version: "1.108.0" } },
32
+ results: [
33
+ {
34
+ ruleId: opts.ruleId ?? "python.lang.security.dangerous-call",
35
+ level: opts.level ?? "warning",
36
+ message: { text: "Dangerous call detected" },
37
+ locations: [
38
+ {
39
+ physicalLocation: {
40
+ artifactLocation: { uri: "src/app.py" },
41
+ region: {
42
+ startLine: opts.line ?? 12,
43
+ startColumn: opts.column ?? 3,
44
+ },
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ };
53
+ }
54
+
55
+ describe("adaptSemgrep", () => {
56
+ it("normalizes tool.driver.name to the canonical 'semgrep' value", () => {
57
+ const result = adaptSemgrep(makeSemgrepSarif({}));
58
+ assert.equal(result.sourceTool, "semgrep");
59
+ const driver = result.document.runs[0]?.tool?.driver;
60
+ assert.equal(driver?.name, "semgrep");
61
+ // Original version should be preserved when present.
62
+ assert.equal(driver?.version, "1.108.0");
63
+ });
64
+
65
+ it("attaches effortMinutes and sourceTool to every result's properties", () => {
66
+ const result = adaptSemgrep(
67
+ makeSemgrepSarif({ ruleId: "javascript.unused-var", level: "warning" }),
68
+ );
69
+ const firstResult = result.document.runs[0]?.results?.[0] as {
70
+ properties?: { effortMinutes?: number; sourceTool?: string };
71
+ };
72
+ assert.equal(firstResult?.properties?.sourceTool, "semgrep");
73
+ assert.ok(
74
+ typeof firstResult?.properties?.effortMinutes === "number" &&
75
+ firstResult.properties.effortMinutes > 0,
76
+ );
77
+ });
78
+
79
+ it("assigns a higher effort budget to security.* rules", () => {
80
+ const securityResult = adaptSemgrep(
81
+ makeSemgrepSarif({ ruleId: "python.lang.security.audit.eval", level: "error" }),
82
+ );
83
+ const stylisticResult = adaptSemgrep(
84
+ makeSemgrepSarif({ ruleId: "python.lang.style.missing-semicolon", level: "warning" }),
85
+ );
86
+ assert.ok(
87
+ securityResult.totalEffortMinutes > stylisticResult.totalEffortMinutes,
88
+ "security rules should cost more than stylistic rules",
89
+ );
90
+ });
91
+
92
+ it("assigns even higher effort to named injection classes (sqli, xss, rce, ...)", () => {
93
+ const sqliResult = adaptSemgrep(
94
+ makeSemgrepSarif({ ruleId: "python.django.sqli-raw-sql", level: "error" }),
95
+ );
96
+ const genericSecurityResult = adaptSemgrep(
97
+ makeSemgrepSarif({ ruleId: "python.lang.security.audit.eval", level: "error" }),
98
+ );
99
+ assert.ok(
100
+ sqliResult.totalEffortMinutes >= genericSecurityResult.totalEffortMinutes,
101
+ "sqli-tagged rules should cost at least as much as generic security rules",
102
+ );
103
+ });
104
+
105
+ it("accepts a JSON string input", () => {
106
+ const raw = JSON.stringify(makeSemgrepSarif({}));
107
+ const result = adaptSemgrep(raw);
108
+ assert.equal(result.findingCount, 1);
109
+ });
110
+
111
+ it("rejects a non-SARIF document", () => {
112
+ assert.throws(() => adaptSemgrep({ version: "2.0.0", runs: [] }));
113
+ });
114
+
115
+ it("rejects a string that is not valid JSON", () => {
116
+ assert.throws(() => adaptSemgrep("not json"));
117
+ });
118
+
119
+ it("does not mutate the caller's document", () => {
120
+ const doc = makeSemgrepSarif({});
121
+ const originalName = doc.runs[0]?.tool?.driver?.name;
122
+ adaptSemgrep(doc);
123
+ assert.equal(doc.runs[0]?.tool?.driver?.name, originalName);
124
+ });
125
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Unit tests for the Stryker mutation-testing adapter.
3
+ *
4
+ * Stryker's mutation report is a per-file map of mutants. The adapter
5
+ * surfaces surviving mutants as SARIF error-level findings, uncovered
6
+ * mutants as warnings, and timeouts as notes. Everything else
7
+ * (killed, compileError, ignored, runtimeError) is suppressed.
8
+ *
9
+ * @module tests/adapters/stryker.test
10
+ */
11
+
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+
15
+ import { adaptStryker } from "../../adapters/stryker.js";
16
+
17
+ /**
18
+ * Helper that builds a Stryker report shell with a single mutant.
19
+ */
20
+ function makeStrykerReport(opts: {
21
+ status: string;
22
+ mutator?: string;
23
+ line?: number;
24
+ column?: number;
25
+ }) {
26
+ return {
27
+ schemaVersion: "1.0",
28
+ files: {
29
+ "src/foo.ts": {
30
+ language: "typescript",
31
+ source: "const x = 1;",
32
+ mutants: [
33
+ {
34
+ id: "1",
35
+ mutatorName: opts.mutator ?? "ConditionalExpression",
36
+ replacement: "false",
37
+ location: {
38
+ start: { line: opts.line ?? 10, column: opts.column ?? 5 },
39
+ end: { line: opts.line ?? 10, column: (opts.column ?? 5) + 6 },
40
+ },
41
+ status: opts.status,
42
+ },
43
+ ],
44
+ },
45
+ },
46
+ };
47
+ }
48
+
49
+ describe("adaptStryker", () => {
50
+ it("flags a Survived mutant as a SARIF error", () => {
51
+ const result = adaptStryker(makeStrykerReport({ status: "Survived" }));
52
+ assert.equal(result.sourceTool, "stryker");
53
+ const finding = result.document.runs[0]?.results?.[0] as { level?: string; ruleId?: string };
54
+ assert.equal(finding?.level, "error");
55
+ assert.equal(finding?.ruleId, "stryker.ConditionalExpression");
56
+ });
57
+
58
+ it("flags a NoCoverage mutant as a SARIF warning", () => {
59
+ const result = adaptStryker(makeStrykerReport({ status: "NoCoverage" }));
60
+ const finding = result.document.runs[0]?.results?.[0] as { level?: string };
61
+ assert.equal(finding?.level, "warning");
62
+ });
63
+
64
+ it("flags a Timeout mutant as a SARIF note", () => {
65
+ const result = adaptStryker(makeStrykerReport({ status: "Timeout" }));
66
+ const finding = result.document.runs[0]?.results?.[0] as { level?: string };
67
+ assert.equal(finding?.level, "note");
68
+ });
69
+
70
+ it("suppresses Killed, Ignored, CompileError, and RuntimeError mutants", () => {
71
+ for (const status of ["Killed", "Ignored", "CompileError", "RuntimeError"]) {
72
+ const result = adaptStryker(makeStrykerReport({ status }));
73
+ assert.equal(result.findingCount, 0, `${status} should produce no findings`);
74
+ }
75
+ });
76
+
77
+ it("preserves mutant id, mutator name, and status on properties", () => {
78
+ const result = adaptStryker(makeStrykerReport({ status: "Survived" }));
79
+ const finding = result.document.runs[0]?.results?.[0] as {
80
+ properties?: { mutantId?: string; mutator?: string; mutantStatus?: string };
81
+ };
82
+ assert.equal(finding?.properties?.mutantId, "1");
83
+ assert.equal(finding?.properties?.mutator, "ConditionalExpression");
84
+ assert.equal(finding?.properties?.mutantStatus, "Survived");
85
+ });
86
+
87
+ it("assigns the correct file URI from the Stryker files{} key", () => {
88
+ const result = adaptStryker(makeStrykerReport({ status: "Survived" }));
89
+ const finding = result.document.runs[0]?.results?.[0] as {
90
+ locations?: Array<{ physicalLocation?: { artifactLocation?: { uri?: string } } }>;
91
+ };
92
+ assert.equal(finding?.locations?.[0]?.physicalLocation?.artifactLocation?.uri, "src/foo.ts");
93
+ });
94
+
95
+ it("accepts a JSON string", () => {
96
+ const raw = JSON.stringify(makeStrykerReport({ status: "Survived" }));
97
+ assert.equal(adaptStryker(raw).findingCount, 1);
98
+ });
99
+
100
+ it("throws on inputs missing the files{} map", () => {
101
+ assert.throws(() => adaptStryker({ schemaVersion: "1.0" }));
102
+ });
103
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Unit tests for the workspace crap-config loader.
3
+ *
4
+ * `.claude-crap.json` lives at the user's workspace root and lets
5
+ * teams pick how strictly claude-crap enforces its Stop quality
6
+ * gate. The loader resolves a single `strictness` value from three
7
+ * possible sources, in priority order:
8
+ *
9
+ * 1. `CLAUDE_CRAP_STRICTNESS` environment variable (session override)
10
+ * 2. `.claude-crap.json` at the workspace root
11
+ * 3. Hardcoded default `"strict"`
12
+ *
13
+ * These tests pin both the characterization invariants (defaults,
14
+ * valid values, precedence) and the attack invariants (invalid
15
+ * values are rejected, not silently downgraded).
16
+ *
17
+ * @module tests/crap-config.test
18
+ */
19
+
20
+ import { describe, it, before, after, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { promises as fs } from "node:fs";
23
+ import { mkdtemp, rm } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import {
28
+ DEFAULT_STRICTNESS,
29
+ STRICTNESS_VALUES,
30
+ CrapConfigError,
31
+ loadCrapConfig,
32
+ type Strictness,
33
+ } from "../crap-config.js";
34
+
35
+ /**
36
+ * Drop every `CLAUDE_CRAP_STRICTNESS` entry from `process.env` for
37
+ * the duration of one test so stray developer environments don't
38
+ * leak into the assertions.
39
+ */
40
+ function unsetStrictnessEnv(): () => void {
41
+ const previous = process.env.CLAUDE_CRAP_STRICTNESS;
42
+ delete process.env.CLAUDE_CRAP_STRICTNESS;
43
+ return () => {
44
+ if (previous === undefined) delete process.env.CLAUDE_CRAP_STRICTNESS;
45
+ else process.env.CLAUDE_CRAP_STRICTNESS = previous;
46
+ };
47
+ }
48
+
49
+ describe("loadCrapConfig — characterization (defaults and valid inputs)", () => {
50
+ let workspace = "";
51
+ let restoreEnv: () => void = () => {};
52
+
53
+ before(async () => {
54
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-config-"));
55
+ });
56
+
57
+ after(async () => {
58
+ if (workspace) await rm(workspace, { recursive: true, force: true });
59
+ });
60
+
61
+ beforeEach(() => {
62
+ restoreEnv = unsetStrictnessEnv();
63
+ });
64
+
65
+ afterEach(() => {
66
+ restoreEnv();
67
+ });
68
+
69
+ it("defaults to 'strict' when neither env nor file is present", () => {
70
+ const config = loadCrapConfig({ workspaceRoot: workspace });
71
+ assert.equal(config.strictness, "strict");
72
+ assert.equal(DEFAULT_STRICTNESS, "strict");
73
+ });
74
+
75
+ it("exposes the exhaustive list of strictness values as a readonly tuple", () => {
76
+ // Sanity check: every value in STRICTNESS_VALUES must be assignable
77
+ // to the Strictness type, and the list must match the intent of
78
+ // this plugin — strict enforces, warn reports, advisory whispers.
79
+ for (const value of STRICTNESS_VALUES) {
80
+ const typed: Strictness = value;
81
+ assert.ok(["strict", "warn", "advisory"].includes(typed));
82
+ }
83
+ assert.equal(STRICTNESS_VALUES.length, 3);
84
+ });
85
+
86
+ it("reads strictness='warn' from .claude-crap.json", async () => {
87
+ const path = join(workspace, ".claude-crap.json");
88
+ await fs.writeFile(path, JSON.stringify({ strictness: "warn" }), "utf8");
89
+ const config = loadCrapConfig({ workspaceRoot: workspace });
90
+ assert.equal(config.strictness, "warn");
91
+ await fs.unlink(path);
92
+ });
93
+
94
+ it("reads strictness='advisory' from .claude-crap.json", async () => {
95
+ const path = join(workspace, ".claude-crap.json");
96
+ await fs.writeFile(path, JSON.stringify({ strictness: "advisory" }), "utf8");
97
+ const config = loadCrapConfig({ workspaceRoot: workspace });
98
+ assert.equal(config.strictness, "advisory");
99
+ await fs.unlink(path);
100
+ });
101
+
102
+ it("env variable wins over .claude-crap.json", async () => {
103
+ const path = join(workspace, ".claude-crap.json");
104
+ await fs.writeFile(path, JSON.stringify({ strictness: "warn" }), "utf8");
105
+ process.env.CLAUDE_CRAP_STRICTNESS = "strict";
106
+ const config = loadCrapConfig({ workspaceRoot: workspace });
107
+ assert.equal(config.strictness, "strict");
108
+ await fs.unlink(path);
109
+ });
110
+
111
+ it("env variable works without any file present", () => {
112
+ process.env.CLAUDE_CRAP_STRICTNESS = "advisory";
113
+ const config = loadCrapConfig({ workspaceRoot: workspace });
114
+ assert.equal(config.strictness, "advisory");
115
+ });
116
+
117
+ it("env variable is trimmed and case-insensitive", () => {
118
+ process.env.CLAUDE_CRAP_STRICTNESS = " WARN ";
119
+ const config = loadCrapConfig({ workspaceRoot: workspace });
120
+ assert.equal(config.strictness, "warn");
121
+ });
122
+
123
+ it("file value is trimmed and case-insensitive", async () => {
124
+ const path = join(workspace, ".claude-crap.json");
125
+ await fs.writeFile(path, JSON.stringify({ strictness: "Advisory" }), "utf8");
126
+ const config = loadCrapConfig({ workspaceRoot: workspace });
127
+ assert.equal(config.strictness, "advisory");
128
+ await fs.unlink(path);
129
+ });
130
+
131
+ it("empty env variable is ignored and falls back to file / default", async () => {
132
+ const path = join(workspace, ".claude-crap.json");
133
+ await fs.writeFile(path, JSON.stringify({ strictness: "advisory" }), "utf8");
134
+ process.env.CLAUDE_CRAP_STRICTNESS = "";
135
+ const config = loadCrapConfig({ workspaceRoot: workspace });
136
+ assert.equal(config.strictness, "advisory");
137
+ await fs.unlink(path);
138
+ });
139
+
140
+ it("file with no strictness key falls back to default", async () => {
141
+ const path = join(workspace, ".claude-crap.json");
142
+ await fs.writeFile(path, JSON.stringify({ somethingElse: true }), "utf8");
143
+ const config = loadCrapConfig({ workspaceRoot: workspace });
144
+ assert.equal(config.strictness, "strict");
145
+ await fs.unlink(path);
146
+ });
147
+ });
148
+
149
+ describe("loadCrapConfig — attack invariants (invalid inputs rejected)", () => {
150
+ let workspace = "";
151
+ let restoreEnv: () => void = () => {};
152
+
153
+ before(async () => {
154
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-config-bad-"));
155
+ });
156
+
157
+ after(async () => {
158
+ if (workspace) await rm(workspace, { recursive: true, force: true });
159
+ });
160
+
161
+ beforeEach(() => {
162
+ restoreEnv = unsetStrictnessEnv();
163
+ });
164
+
165
+ afterEach(() => {
166
+ restoreEnv();
167
+ });
168
+
169
+ it("throws CrapConfigError on invalid env variable value", () => {
170
+ process.env.CLAUDE_CRAP_STRICTNESS = "lenient"; // not in the enum
171
+ assert.throws(
172
+ () => loadCrapConfig({ workspaceRoot: workspace }),
173
+ CrapConfigError,
174
+ );
175
+ });
176
+
177
+ it("throws CrapConfigError on invalid file strictness value", async () => {
178
+ const path = join(workspace, ".claude-crap.json");
179
+ await fs.writeFile(path, JSON.stringify({ strictness: "lenient" }), "utf8");
180
+ assert.throws(
181
+ () => loadCrapConfig({ workspaceRoot: workspace }),
182
+ CrapConfigError,
183
+ );
184
+ await fs.unlink(path);
185
+ });
186
+
187
+ it("throws CrapConfigError on a malformed JSON file", async () => {
188
+ const path = join(workspace, ".claude-crap.json");
189
+ await fs.writeFile(path, "{ this is not json", "utf8");
190
+ assert.throws(
191
+ () => loadCrapConfig({ workspaceRoot: workspace }),
192
+ CrapConfigError,
193
+ );
194
+ await fs.unlink(path);
195
+ });
196
+
197
+ it("throws CrapConfigError when strictness is the wrong JSON type", async () => {
198
+ const path = join(workspace, ".claude-crap.json");
199
+ await fs.writeFile(path, JSON.stringify({ strictness: 42 }), "utf8");
200
+ assert.throws(
201
+ () => loadCrapConfig({ workspaceRoot: workspace }),
202
+ CrapConfigError,
203
+ );
204
+ await fs.unlink(path);
205
+ });
206
+
207
+ it("throws CrapConfigError when the JSON root is not an object", async () => {
208
+ const path = join(workspace, ".claude-crap.json");
209
+ await fs.writeFile(path, JSON.stringify(["strict"]), "utf8");
210
+ assert.throws(
211
+ () => loadCrapConfig({ workspaceRoot: workspace }),
212
+ CrapConfigError,
213
+ );
214
+ await fs.unlink(path);
215
+ });
216
+
217
+ it("CrapConfigError error messages identify the source that was invalid", () => {
218
+ process.env.CLAUDE_CRAP_STRICTNESS = "supercritical";
219
+ try {
220
+ loadCrapConfig({ workspaceRoot: workspace });
221
+ assert.fail("expected loadCrapConfig to throw");
222
+ } catch (err) {
223
+ assert.ok(err instanceof CrapConfigError);
224
+ assert.match(err.message, /CLAUDE_CRAP_STRICTNESS/);
225
+ assert.match(err.message, /supercritical/);
226
+ }
227
+ });
228
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Unit tests for the CRAP engine.
3
+ *
4
+ * Uses Node's built-in `node:test` runner so the test suite ships with no
5
+ * extra dependencies. Run with `npm test` from `src/mcp-server`.
6
+ *
7
+ * @module tests/crap.test
8
+ */
9
+
10
+ import { describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import { computeCrap } from "../metrics/crap.js";
14
+
15
+ describe("computeCrap", () => {
16
+ it("returns the baseline complexity when coverage is 100%", () => {
17
+ const result = computeCrap({ cyclomaticComplexity: 10, coveragePercent: 100 }, 30);
18
+ // With full coverage the cubic term vanishes and only the +comp(m)
19
+ // tail remains. This is the property that forces decomposition of
20
+ // functions with complexity ≥ 30 even when perfectly tested.
21
+ assert.equal(result.crap, 10);
22
+ assert.equal(result.exceedsThreshold, false);
23
+ });
24
+
25
+ it("explodes cubically when coverage is 0%", () => {
26
+ const result = computeCrap({ cyclomaticComplexity: 10, coveragePercent: 0 }, 30);
27
+ // CRAP = 10² × 1³ + 10 = 110
28
+ assert.equal(result.crap, 110);
29
+ assert.equal(result.exceedsThreshold, true);
30
+ });
31
+
32
+ it("respects the threshold for partially tested code", () => {
33
+ // 12 branches, 60% coverage
34
+ // CRAP = 12² × 0.4³ + 12 = 144 × 0.064 + 12 = 9.216 + 12 = 21.216
35
+ const result = computeCrap({ cyclomaticComplexity: 12, coveragePercent: 60 }, 30);
36
+ assert.equal(result.crap, 21.216);
37
+ assert.equal(result.exceedsThreshold, false);
38
+ });
39
+
40
+ it("blocks any function above complexity 30 regardless of coverage", () => {
41
+ // With comp = 31 and 100% coverage, the tail alone is 31 > 30.
42
+ const result = computeCrap({ cyclomaticComplexity: 31, coveragePercent: 100 }, 30);
43
+ assert.equal(result.crap, 31);
44
+ assert.equal(result.exceedsThreshold, true);
45
+ });
46
+
47
+ it("rejects negative complexity", () => {
48
+ assert.throws(() => computeCrap({ cyclomaticComplexity: 0, coveragePercent: 50 }, 30));
49
+ });
50
+
51
+ it("rejects coverage outside [0, 100]", () => {
52
+ assert.throws(() => computeCrap({ cyclomaticComplexity: 5, coveragePercent: -1 }, 30));
53
+ assert.throws(() => computeCrap({ cyclomaticComplexity: 5, coveragePercent: 101 }, 30));
54
+ });
55
+
56
+ it("rejects non-positive thresholds", () => {
57
+ assert.throws(() => computeCrap({ cyclomaticComplexity: 5, coveragePercent: 50 }, 0));
58
+ });
59
+ });