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,86 @@
1
+ /**
2
+ * Unit tests for the Technical Debt Ratio engine and its letter grading.
3
+ *
4
+ * @module tests/tdr.test
5
+ */
6
+
7
+ import { describe, it } from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import { classifyTdr, computeTdr, ratingIsWorseThan, ratingToRank } from "../metrics/tdr.js";
11
+
12
+ describe("classifyTdr", () => {
13
+ it("maps 0–5% to A", () => {
14
+ assert.equal(classifyTdr(0), "A");
15
+ assert.equal(classifyTdr(5), "A");
16
+ });
17
+
18
+ it("maps >5–10% to B", () => {
19
+ assert.equal(classifyTdr(5.0001), "B");
20
+ assert.equal(classifyTdr(10), "B");
21
+ });
22
+
23
+ it("maps >10–20% to C", () => {
24
+ assert.equal(classifyTdr(10.0001), "C");
25
+ assert.equal(classifyTdr(20), "C");
26
+ });
27
+
28
+ it("maps >20–50% to D", () => {
29
+ assert.equal(classifyTdr(20.0001), "D");
30
+ assert.equal(classifyTdr(50), "D");
31
+ });
32
+
33
+ it("maps >50% to E", () => {
34
+ assert.equal(classifyTdr(50.0001), "E");
35
+ assert.equal(classifyTdr(999), "E");
36
+ });
37
+
38
+ it("rejects negative percentages", () => {
39
+ assert.throws(() => classifyTdr(-0.01));
40
+ });
41
+ });
42
+
43
+ describe("computeTdr", () => {
44
+ it("produces an A rating for healthy projects", () => {
45
+ // 240 minutes of remediation / (30 × 500) = 0.016 = 1.6%
46
+ const result = computeTdr({
47
+ remediationMinutes: 240,
48
+ totalLinesOfCode: 500,
49
+ minutesPerLoc: 30,
50
+ });
51
+ assert.equal(result.percent, 1.6);
52
+ assert.equal(result.rating, "A");
53
+ assert.equal(result.developmentCostMinutes, 15_000);
54
+ });
55
+
56
+ it("produces an E rating for unmaintainable projects", () => {
57
+ // 9000 minutes on 100 LOC at 30 min/LOC → 300%
58
+ const result = computeTdr({
59
+ remediationMinutes: 9000,
60
+ totalLinesOfCode: 100,
61
+ minutesPerLoc: 30,
62
+ });
63
+ assert.equal(result.rating, "E");
64
+ assert.ok(result.percent > 50);
65
+ });
66
+
67
+ it("rejects a non-positive LOC denominator", () => {
68
+ assert.throws(() =>
69
+ computeTdr({ remediationMinutes: 10, totalLinesOfCode: 0, minutesPerLoc: 30 }),
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("rating helpers", () => {
75
+ it("ranks A..E in the expected order", () => {
76
+ assert.equal(ratingToRank("A"), 0);
77
+ assert.equal(ratingToRank("C"), 2);
78
+ assert.equal(ratingToRank("E"), 4);
79
+ });
80
+
81
+ it("detects when actual is worse than the policy limit", () => {
82
+ assert.equal(ratingIsWorseThan("D", "C"), true);
83
+ assert.equal(ratingIsWorseThan("A", "C"), false);
84
+ assert.equal(ratingIsWorseThan("C", "C"), false); // equal is not worse
85
+ });
86
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Unit tests for the test-file resolver used by `require_test_harness`.
3
+ *
4
+ * Uses a temp workspace populated with a handful of source and test
5
+ * files so we can exercise every resolver convention (sibling, mirror
6
+ * tree, Python prefix) in isolation.
7
+ *
8
+ * @module tests/test-harness.test
9
+ */
10
+
11
+ import { describe, it, before, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { promises as fs } from "node:fs";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import { candidatePaths, findTestFile, isTestFile } from "../tools/test-harness.js";
19
+
20
+ describe("isTestFile", () => {
21
+ it("recognizes .test.ts suffix", () => {
22
+ assert.equal(isTestFile("src/foo.test.ts"), true);
23
+ });
24
+
25
+ it("recognizes .spec.js suffix", () => {
26
+ assert.equal(isTestFile("app/bar.spec.js"), true);
27
+ });
28
+
29
+ it("recognizes python test_ prefix", () => {
30
+ assert.equal(isTestFile("pkg/test_mod.py"), true);
31
+ });
32
+
33
+ it("recognizes __tests__ directory name", () => {
34
+ assert.equal(isTestFile("src/__tests__/foo.ts"), true);
35
+ });
36
+
37
+ it("rejects plain source files", () => {
38
+ assert.equal(isTestFile("src/foo.ts"), false);
39
+ assert.equal(isTestFile("src/utils/math.py"), false);
40
+ });
41
+ });
42
+
43
+ describe("candidatePaths", () => {
44
+ it("lists sibling test locations first", () => {
45
+ const candidates = candidatePaths("/ws", "/ws/src/foo.ts");
46
+ assert.ok(candidates.includes("/ws/src/foo.test.ts"));
47
+ assert.ok(candidates.includes("/ws/src/foo.spec.ts"));
48
+ });
49
+
50
+ it("includes mirror-tree candidates under tests/", () => {
51
+ const candidates = candidatePaths("/ws", "/ws/src/foo.ts");
52
+ assert.ok(candidates.includes("/ws/tests/src/foo.test.ts"));
53
+ });
54
+
55
+ it("includes nearest-ancestor flat tests directory candidates", () => {
56
+ // For src/mcp-server/src/metrics/crap.ts the walker should probe
57
+ // every ancestor `tests/` dir up to the workspace root.
58
+ const candidates = candidatePaths(
59
+ "/ws",
60
+ "/ws/src/mcp-server/src/metrics/crap.ts",
61
+ );
62
+ assert.ok(candidates.includes("/ws/src/mcp-server/src/metrics/tests/crap.test.ts"));
63
+ assert.ok(candidates.includes("/ws/src/mcp-server/src/tests/crap.test.ts"));
64
+ assert.ok(candidates.includes("/ws/src/mcp-server/tests/crap.test.ts"));
65
+ assert.ok(candidates.includes("/ws/src/tests/crap.test.ts"));
66
+ assert.ok(candidates.includes("/ws/tests/crap.test.ts"));
67
+ });
68
+
69
+ it("stops walking at the workspace root", () => {
70
+ // Make sure the walker does not produce candidates OUTSIDE the workspace.
71
+ const candidates = candidatePaths("/ws", "/ws/src/foo.ts");
72
+ for (const c of candidates) {
73
+ assert.ok(c.startsWith("/ws"), `candidate '${c}' escaped the workspace root`);
74
+ }
75
+ });
76
+
77
+ it("adds python test_ prefix candidates for .py files", () => {
78
+ const candidates = candidatePaths("/ws", "/ws/pkg/mod.py");
79
+ assert.ok(candidates.includes("/ws/pkg/test_mod.py"));
80
+ assert.ok(candidates.includes("/ws/tests/test_mod.py"));
81
+ });
82
+ });
83
+
84
+ describe("findTestFile", () => {
85
+ let workspace = "";
86
+
87
+ before(async () => {
88
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-th-"));
89
+ // src/foo.ts with a sibling test
90
+ await fs.mkdir(join(workspace, "src"), { recursive: true });
91
+ await fs.writeFile(join(workspace, "src", "foo.ts"), "// source");
92
+ await fs.writeFile(join(workspace, "src", "foo.test.ts"), "// test");
93
+ // src/bar.ts with a mirror-tree test
94
+ await fs.writeFile(join(workspace, "src", "bar.ts"), "// source");
95
+ await fs.mkdir(join(workspace, "tests", "src"), { recursive: true });
96
+ await fs.writeFile(join(workspace, "tests", "src", "bar.test.ts"), "// test");
97
+ // src/baz.ts with no test at all
98
+ await fs.writeFile(join(workspace, "src", "baz.ts"), "// source");
99
+ // pkg/mod.py with a sibling python test
100
+ await fs.mkdir(join(workspace, "pkg"), { recursive: true });
101
+ await fs.writeFile(join(workspace, "pkg", "mod.py"), "# source");
102
+ await fs.writeFile(join(workspace, "pkg", "test_mod.py"), "# test");
103
+ // Flat-tests-dir layout: src/mcp/src/metrics/qux.ts tested by
104
+ // src/mcp/src/tests/qux.test.ts (mirrors this very project's layout).
105
+ await fs.mkdir(join(workspace, "src", "mcp", "src", "metrics"), { recursive: true });
106
+ await fs.writeFile(join(workspace, "src", "mcp", "src", "metrics", "qux.ts"), "// source");
107
+ await fs.mkdir(join(workspace, "src", "mcp", "src", "tests"), { recursive: true });
108
+ await fs.writeFile(join(workspace, "src", "mcp", "src", "tests", "qux.test.ts"), "// test");
109
+ });
110
+
111
+ after(async () => {
112
+ if (workspace) await rm(workspace, { recursive: true, force: true });
113
+ });
114
+
115
+ it("finds a sibling .test.ts", async () => {
116
+ const res = await findTestFile(workspace, join(workspace, "src", "foo.ts"));
117
+ assert.equal(res.isTestFile, false);
118
+ assert.equal(res.testFile, join(workspace, "src", "foo.test.ts"));
119
+ });
120
+
121
+ it("finds a mirror-tree test", async () => {
122
+ const res = await findTestFile(workspace, join(workspace, "src", "bar.ts"));
123
+ assert.equal(res.testFile, join(workspace, "tests", "src", "bar.test.ts"));
124
+ });
125
+
126
+ it("returns null when no test exists", async () => {
127
+ const res = await findTestFile(workspace, join(workspace, "src", "baz.ts"));
128
+ assert.equal(res.testFile, null);
129
+ assert.ok(res.candidates.length > 0);
130
+ });
131
+
132
+ it("short-circuits when input is already a test file", async () => {
133
+ const res = await findTestFile(workspace, join(workspace, "src", "foo.test.ts"));
134
+ assert.equal(res.isTestFile, true);
135
+ assert.equal(res.testFile, join(workspace, "src", "foo.test.ts"));
136
+ });
137
+
138
+ it("finds a python test_ prefix file", async () => {
139
+ const res = await findTestFile(workspace, join(workspace, "pkg", "mod.py"));
140
+ assert.equal(res.testFile, join(workspace, "pkg", "test_mod.py"));
141
+ });
142
+
143
+ it("finds a test inside a flat ancestor tests/ directory", async () => {
144
+ const res = await findTestFile(
145
+ workspace,
146
+ join(workspace, "src", "mcp", "src", "metrics", "qux.ts"),
147
+ );
148
+ assert.equal(
149
+ res.testFile,
150
+ join(workspace, "src", "mcp", "src", "tests", "qux.test.ts"),
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Unit tests for the workspace path containment guard.
3
+ *
4
+ * F-A01-01: the previous inlined guard used a naive
5
+ * `candidate.startsWith(workspace)` check that was fooled by
6
+ * sibling-prefix paths (e.g. `/tmp/ws-evil` vs `/tmp/ws`). These
7
+ * tests pin both the characterization invariants (paths inside the
8
+ * workspace still resolve, paths outside still throw) and the attack
9
+ * invariant (sibling-prefix paths now throw).
10
+ *
11
+ * The tests use a temp directory to avoid any dependency on the
12
+ * developer's local filesystem layout.
13
+ *
14
+ * @module tests/workspace-guard.test
15
+ */
16
+
17
+ import { describe, it, before, after } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { mkdtemp, rm } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join, sep } from "node:path";
22
+
23
+ import { resolveWithinWorkspace } from "../workspace-guard.js";
24
+
25
+ describe("resolveWithinWorkspace — characterization (well-formed paths)", () => {
26
+ let workspace = "";
27
+
28
+ before(async () => {
29
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-wg-"));
30
+ });
31
+
32
+ after(async () => {
33
+ if (workspace) await rm(workspace, { recursive: true, force: true });
34
+ });
35
+
36
+ it("accepts a relative path resolved against the workspace", () => {
37
+ const resolved = resolveWithinWorkspace(workspace, "src/foo.ts");
38
+ assert.equal(resolved, join(workspace, "src", "foo.ts"));
39
+ });
40
+
41
+ it("accepts an absolute path already inside the workspace", () => {
42
+ const input = join(workspace, "src", "bar.ts");
43
+ const resolved = resolveWithinWorkspace(workspace, input);
44
+ assert.equal(resolved, input);
45
+ });
46
+
47
+ it("accepts the workspace root itself", () => {
48
+ const resolved = resolveWithinWorkspace(workspace, workspace);
49
+ assert.equal(resolved, workspace);
50
+ });
51
+
52
+ it("accepts a deeply nested path inside the workspace", () => {
53
+ const resolved = resolveWithinWorkspace(workspace, "a/b/c/d/e.ts");
54
+ assert.equal(resolved, join(workspace, "a", "b", "c", "d", "e.ts"));
55
+ });
56
+ });
57
+
58
+ describe("resolveWithinWorkspace — attack invariants (outside paths rejected)", () => {
59
+ let workspace = "";
60
+
61
+ before(async () => {
62
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-wg-"));
63
+ });
64
+
65
+ after(async () => {
66
+ if (workspace) await rm(workspace, { recursive: true, force: true });
67
+ });
68
+
69
+ it("rejects an absolute path completely outside the workspace", () => {
70
+ assert.throws(
71
+ () => resolveWithinWorkspace(workspace, "/etc/passwd"),
72
+ /escapes the workspace root/,
73
+ );
74
+ });
75
+
76
+ it("rejects a parent-directory relative escape", () => {
77
+ assert.throws(
78
+ () => resolveWithinWorkspace(workspace, "../../../etc/passwd"),
79
+ /escapes the workspace root/,
80
+ );
81
+ });
82
+
83
+ it("rejects a sibling directory that shares the workspace prefix (F-A01-01)", () => {
84
+ // This is the exact attack class F-A01-01 covers: the sibling path
85
+ // starts with the literal workspace string but is NOT contained in
86
+ // it. The old `startsWith(workspace)` check would have accepted
87
+ // this. The fixed check uses `workspace + sep` so it rejects.
88
+ const siblingPrefix = `${workspace}-evil${sep}secret.txt`;
89
+ assert.throws(
90
+ () => resolveWithinWorkspace(workspace, siblingPrefix),
91
+ /escapes the workspace root/,
92
+ );
93
+ });
94
+
95
+ it("rejects another sibling-prefix variant (workspace-clone)", () => {
96
+ const siblingPrefix = `${workspace}-clone${sep}a${sep}b.ts`;
97
+ assert.throws(
98
+ () => resolveWithinWorkspace(workspace, siblingPrefix),
99
+ /escapes the workspace root/,
100
+ );
101
+ });
102
+
103
+ it("rejects an empty relative path that resolves to a parent", () => {
104
+ // `..` alone resolves to the parent directory, which is always
105
+ // outside the workspace for a tempdir.
106
+ assert.throws(
107
+ () => resolveWithinWorkspace(workspace, ".."),
108
+ /escapes the workspace root/,
109
+ );
110
+ });
111
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Public SDK entry point for the tool backends that sit behind
3
+ * the `claude-crap` MCP server.
4
+ *
5
+ * These are the same pure functions the MCP server calls into — the
6
+ * server layer just wraps them in JSON-RPC. Downstream consumers can
7
+ * reuse them directly from any Node.js context without running the
8
+ * MCP server.
9
+ *
10
+ * Usage:
11
+ *
12
+ * ```ts
13
+ * import {
14
+ * findTestFile,
15
+ * isTestFile,
16
+ * candidatePaths,
17
+ * } from "claude-crap/tools";
18
+ * ```
19
+ *
20
+ * @module tools
21
+ */
22
+
23
+ export { candidatePaths, findTestFile, isTestFile } from "./test-harness.js";
24
+ export type { TestFileResolution } from "./test-harness.js";
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Deterministic test-file resolver used by the `require_test_harness`
3
+ * MCP tool.
4
+ *
5
+ * Given a production source file (for example `src/foo/bar.ts`), this
6
+ * module enumerates the conventional locations where a matching test
7
+ * file would live and returns the first existing match — or `null` when
8
+ * none of the candidates exist.
9
+ *
10
+ * This is a TypeScript twin of `hooks/lib/test-harness.mjs`. The two
11
+ * are intentionally independent so neither side has to import files
12
+ * from outside its own project tree:
13
+ *
14
+ * - Hooks use the `.mjs` copy (vanilla JS, zero deps, runs everywhere).
15
+ * - The MCP server uses this typed copy so its consumers get full
16
+ * type safety and so the server stays a self-contained npm package.
17
+ *
18
+ * Both copies implement the same conventions and are validated against
19
+ * the same unit tests — see `src/tests/test-harness.test.ts`.
20
+ *
21
+ * @module tools/test-harness
22
+ */
23
+
24
+ import { promises as fs } from "node:fs";
25
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
+
27
+ /**
28
+ * Result of probing the filesystem for a matching test file.
29
+ */
30
+ export interface TestFileResolution {
31
+ /** Absolute path of the first matching test file, or `null` when none exists. */
32
+ readonly testFile: string | null;
33
+ /** Absolute paths of every location the resolver tried. */
34
+ readonly candidates: ReadonlyArray<string>;
35
+ /** `true` when the input path itself is a test file. */
36
+ readonly isTestFile: boolean;
37
+ }
38
+
39
+ /** Matches `.test.` and `.spec.` suffixes inside a file basename. */
40
+ const TEST_SUFFIX_PATTERN = /\.(test|spec)\./;
41
+
42
+ /**
43
+ * Return `true` when the given path is already a test file. Matching is
44
+ * done against the basename (`foo.test.ts`, `test_foo.py`) and against
45
+ * common test directory names in the path (`__tests__`, `tests`, `test`).
46
+ *
47
+ * @param filePath An absolute or relative source path.
48
+ */
49
+ export function isTestFile(filePath: string): boolean {
50
+ const base = basename(filePath);
51
+ if (TEST_SUFFIX_PATTERN.test(base)) return true;
52
+ if (base.startsWith("test_") && base.endsWith(".py")) return true;
53
+ const parts = filePath.split(sep);
54
+ return parts.includes("__tests__") || parts.includes("tests") || parts.includes("test");
55
+ }
56
+
57
+ /**
58
+ * Enumerate every plausible test file path for a given production source
59
+ * file. Does not touch the filesystem — the caller is expected to probe
60
+ * existence separately (see {@link findTestFile}).
61
+ *
62
+ * Supported conventions, in the order they are probed:
63
+ *
64
+ * 1. Sibling `<base>.test.<ext>` / `<base>.spec.<ext>`
65
+ * 2. Sibling `__tests__/<base>.test.<ext>`
66
+ * 3. Mirror tree under `tests/`, `test/`, or `__tests__/` at the
67
+ * workspace root (e.g. `tests/src/foo/bar.test.ts`)
68
+ * 4. **Nearest-ancestor flat test directory**: walk up from the source
69
+ * file's directory toward the workspace root, and at every ancestor
70
+ * check for `tests/<base>.test.<ext>`. Matches layouts where tests
71
+ * live in a single flat directory near the source (this project
72
+ * uses it for `src/mcp-server/src/tests/`).
73
+ * 5. Python-specific: sibling `test_<base>.py` and mirror-tree
74
+ * `tests/.../test_<base>.py`.
75
+ *
76
+ * @param workspaceRoot Absolute workspace root.
77
+ * @param filePath Absolute path to the production file.
78
+ * @returns Ordered list of absolute candidate paths.
79
+ */
80
+ export function candidatePaths(workspaceRoot: string, filePath: string): ReadonlyArray<string> {
81
+ const absSource = resolve(filePath);
82
+ const ext = extname(absSource);
83
+ const base = basename(absSource, ext);
84
+ const dir = dirname(absSource);
85
+ const absWorkspace = resolve(workspaceRoot);
86
+ const relFromRoot = relative(absWorkspace, absSource);
87
+ const relDir = dirname(relFromRoot);
88
+
89
+ const candidates = new Set<string>();
90
+
91
+ // 1. Sibling <base>.test.<ext> / <base>.spec.<ext>
92
+ candidates.add(join(dir, `${base}.test${ext}`));
93
+ candidates.add(join(dir, `${base}.spec${ext}`));
94
+
95
+ // 2. Sibling __tests__/<base>.test.<ext>
96
+ candidates.add(join(dir, "__tests__", `${base}.test${ext}`));
97
+ candidates.add(join(dir, "__tests__", `${base}.spec${ext}`));
98
+
99
+ // 3. Mirror tree under tests/, test/, or __tests__ at the workspace root.
100
+ for (const testRoot of ["tests", "test", "__tests__"]) {
101
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}.test${ext}`));
102
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}.spec${ext}`));
103
+ candidates.add(join(absWorkspace, testRoot, relDir, `${base}${ext}`));
104
+ }
105
+
106
+ // 4. Nearest-ancestor flat test directory. Walk up from `dir` to
107
+ // `absWorkspace`, and at each ancestor probe for a flat
108
+ // `tests/<base>.test.<ext>` (or `test/`, `__tests__/`) layout.
109
+ let current = dir;
110
+ while (current.length >= absWorkspace.length) {
111
+ for (const testRoot of ["tests", "test", "__tests__"]) {
112
+ candidates.add(join(current, testRoot, `${base}.test${ext}`));
113
+ candidates.add(join(current, testRoot, `${base}.spec${ext}`));
114
+ candidates.add(join(current, testRoot, `${base}${ext}`));
115
+ }
116
+ if (current === absWorkspace) break;
117
+ const parent = dirname(current);
118
+ if (parent === current) break; // reached filesystem root, stop
119
+ current = parent;
120
+ }
121
+
122
+ // 5. Python-specific variants.
123
+ if (ext === ".py") {
124
+ candidates.add(join(dir, `test_${base}.py`));
125
+ candidates.add(join(absWorkspace, "tests", `test_${base}.py`));
126
+ candidates.add(join(absWorkspace, "tests", relDir, `test_${base}.py`));
127
+ }
128
+
129
+ return Array.from(candidates);
130
+ }
131
+
132
+ /**
133
+ * Probe the filesystem and return the first candidate that exists, or
134
+ * `null` when none of them do. Returns early with `isTestFile: true`
135
+ * when the input is already a test file.
136
+ *
137
+ * @param workspaceRoot Absolute workspace root.
138
+ * @param filePath Absolute or relative path to the production file.
139
+ */
140
+ export async function findTestFile(
141
+ workspaceRoot: string,
142
+ filePath: string,
143
+ ): Promise<TestFileResolution> {
144
+ const absolute = isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
145
+ if (isTestFile(absolute)) {
146
+ return { testFile: absolute, candidates: [absolute], isTestFile: true };
147
+ }
148
+ const candidates = candidatePaths(workspaceRoot, absolute);
149
+ for (const candidate of candidates) {
150
+ try {
151
+ await fs.access(candidate);
152
+ return { testFile: candidate, candidates, isTestFile: false };
153
+ } catch {
154
+ // Probe next candidate.
155
+ }
156
+ }
157
+ return { testFile: null, candidates, isTestFile: false };
158
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Workspace path containment guard.
3
+ *
4
+ * Every MCP tool that accepts a user-supplied file path routes it
5
+ * through {@link resolveWithinWorkspace} before touching the
6
+ * filesystem. The guard rejects any resolved absolute path that is
7
+ * outside the configured workspace root, so the agent cannot be
8
+ * tricked (via prompt injection through scanner output, or via its
9
+ * own confusion) into reading files that live next to the project
10
+ * but outside it.
11
+ *
12
+ * F-A01-01: the original guard in `src/index.ts` used a naive
13
+ * `candidate.startsWith(workspace)` check, which suffers from prefix
14
+ * confusion — for a workspace like `/Users/x/claude-crap`, an
15
+ * absolute input path such as `/Users/x/claude-crap-evil/secret.ts`
16
+ * would pass the check because the two share the literal prefix up
17
+ * to the final segment. This module replaces that check with a
18
+ * separator-aware comparison: the candidate is only accepted if it
19
+ * equals the workspace exactly OR begins with `workspace + sep`.
20
+ *
21
+ * This module is intentionally pure (no I/O, no global state) so it
22
+ * can be unit-tested without any fixtures.
23
+ *
24
+ * @module workspace-guard
25
+ */
26
+
27
+ import { isAbsolute, resolve, sep } from "node:path";
28
+
29
+ /**
30
+ * Resolve a user-supplied file path against a workspace root, returning
31
+ * the absolute path only if it is contained inside the root. Throws a
32
+ * descriptive error when the resolved candidate escapes the workspace.
33
+ *
34
+ * Rules enforced (must stay in sync with
35
+ * `src/tests/workspace-guard.test.ts`):
36
+ *
37
+ * 1. Relative paths are resolved against `workspaceRoot`.
38
+ * 2. Absolute paths are accepted as-is for resolution.
39
+ * 3. The resolved candidate must equal `workspaceRoot` OR begin with
40
+ * `workspaceRoot + sep`. Sibling directories that merely share a
41
+ * prefix (e.g. `/tmp/workspace-evil` vs `/tmp/workspace`) are
42
+ * rejected.
43
+ * 4. The comparison uses the platform's native path separator, so
44
+ * the guard works on both POSIX and Windows.
45
+ *
46
+ * @param workspaceRoot Absolute or relative path to the workspace root.
47
+ * Non-absolute values are resolved against the
48
+ * current working directory, which matches the
49
+ * behavior of the previous in-lined guard.
50
+ * @param filePath User-supplied path. May be absolute or relative
51
+ * to the workspace root.
52
+ * @returns The absolute, workspace-contained path.
53
+ * @throws `Error` when the candidate escapes the workspace.
54
+ */
55
+ export function resolveWithinWorkspace(workspaceRoot: string, filePath: string): string {
56
+ const workspace = resolve(workspaceRoot);
57
+ const candidate = isAbsolute(filePath) ? resolve(filePath) : resolve(workspace, filePath);
58
+ if (candidate !== workspace && !candidate.startsWith(workspace + sep)) {
59
+ throw new Error(
60
+ `[claude-crap] Refusing to access '${filePath}' — path escapes the workspace root`,
61
+ );
62
+ }
63
+ return candidate;
64
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2023"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "noImplicitAny": true,
11
+ "strictNullChecks": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noUncheckedIndexedAccess": true,
15
+ "exactOptionalPropertyTypes": true,
16
+ "esModuleInterop": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "skipLibCheck": true,
19
+ "declaration": true,
20
+ "declarationMap": true,
21
+ "sourceMap": true,
22
+ "resolveJsonModule": true,
23
+ "isolatedModules": true
24
+ },
25
+ "include": ["src/**/*"],
26
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
27
+ }