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.
- package/CHANGELOG.md +308 -0
- package/LICENSE +21 -0
- package/README.md +550 -0
- package/bin/claude-crap.mjs +141 -0
- package/dist/adapters/bandit.d.ts +48 -0
- package/dist/adapters/bandit.d.ts.map +1 -0
- package/dist/adapters/bandit.js +145 -0
- package/dist/adapters/bandit.js.map +1 -0
- package/dist/adapters/common.d.ts +73 -0
- package/dist/adapters/common.d.ts.map +1 -0
- package/dist/adapters/common.js +78 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/eslint.d.ts +52 -0
- package/dist/adapters/eslint.d.ts.map +1 -0
- package/dist/adapters/eslint.js +142 -0
- package/dist/adapters/eslint.js.map +1 -0
- package/dist/adapters/index.d.ts +47 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +64 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/semgrep.d.ts +30 -0
- package/dist/adapters/semgrep.d.ts.map +1 -0
- package/dist/adapters/semgrep.js +130 -0
- package/dist/adapters/semgrep.js.map +1 -0
- package/dist/adapters/stryker.d.ts +55 -0
- package/dist/adapters/stryker.d.ts.map +1 -0
- package/dist/adapters/stryker.js +165 -0
- package/dist/adapters/stryker.js.map +1 -0
- package/dist/ast/cyclomatic.d.ts +48 -0
- package/dist/ast/cyclomatic.d.ts.map +1 -0
- package/dist/ast/cyclomatic.js +106 -0
- package/dist/ast/cyclomatic.js.map +1 -0
- package/dist/ast/index.d.ts +26 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +23 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/language-config.d.ts +70 -0
- package/dist/ast/language-config.d.ts.map +1 -0
- package/dist/ast/language-config.js +192 -0
- package/dist/ast/language-config.js.map +1 -0
- package/dist/ast/tree-sitter-engine.d.ts +133 -0
- package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
- package/dist/ast/tree-sitter-engine.js +270 -0
- package/dist/ast/tree-sitter-engine.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +78 -0
- package/dist/config.js.map +1 -0
- package/dist/crap-config.d.ts +97 -0
- package/dist/crap-config.d.ts.map +1 -0
- package/dist/crap-config.js +144 -0
- package/dist/crap-config.js.map +1 -0
- package/dist/dashboard/server.d.ts +65 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +147 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/crap.d.ts +71 -0
- package/dist/metrics/crap.d.ts.map +1 -0
- package/dist/metrics/crap.js +67 -0
- package/dist/metrics/crap.js.map +1 -0
- package/dist/metrics/index.d.ts +31 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +27 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/score.d.ts +143 -0
- package/dist/metrics/score.d.ts.map +1 -0
- package/dist/metrics/score.js +224 -0
- package/dist/metrics/score.js.map +1 -0
- package/dist/metrics/tdr.d.ts +106 -0
- package/dist/metrics/tdr.d.ts.map +1 -0
- package/dist/metrics/tdr.js +117 -0
- package/dist/metrics/tdr.js.map +1 -0
- package/dist/metrics/workspace-walker.d.ts +43 -0
- package/dist/metrics/workspace-walker.d.ts.map +1 -0
- package/dist/metrics/workspace-walker.js +137 -0
- package/dist/metrics/workspace-walker.js.map +1 -0
- package/dist/sarif/index.d.ts +21 -0
- package/dist/sarif/index.d.ts.map +1 -0
- package/dist/sarif/index.js +19 -0
- package/dist/sarif/index.js.map +1 -0
- package/dist/sarif/sarif-builder.d.ts +128 -0
- package/dist/sarif/sarif-builder.d.ts.map +1 -0
- package/dist/sarif/sarif-builder.js +79 -0
- package/dist/sarif/sarif-builder.js.map +1 -0
- package/dist/sarif/sarif-store.d.ts +205 -0
- package/dist/sarif/sarif-store.d.ts.map +1 -0
- package/dist/sarif/sarif-store.js +246 -0
- package/dist/sarif/sarif-store.js.map +1 -0
- package/dist/sarif/sarif-validator.d.ts +45 -0
- package/dist/sarif/sarif-validator.d.ts.map +1 -0
- package/dist/sarif/sarif-validator.js +138 -0
- package/dist/sarif/sarif-validator.js.map +1 -0
- package/dist/schemas/tool-schemas.d.ts +216 -0
- package/dist/schemas/tool-schemas.d.ts.map +1 -0
- package/dist/schemas/tool-schemas.js +208 -0
- package/dist/schemas/tool-schemas.js.map +1 -0
- package/dist/sdk.d.ts +45 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +44 -0
- package/dist/sdk.js.map +1 -0
- package/dist/tools/index.d.ts +24 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/test-harness.d.ts +75 -0
- package/dist/tools/test-harness.d.ts.map +1 -0
- package/dist/tools/test-harness.js +137 -0
- package/dist/tools/test-harness.js.map +1 -0
- package/dist/workspace-guard.d.ts +53 -0
- package/dist/workspace-guard.d.ts.map +1 -0
- package/dist/workspace-guard.js +61 -0
- package/dist/workspace-guard.js.map +1 -0
- package/package.json +133 -0
- package/plugin/.claude-plugin/plugin.json +29 -0
- package/plugin/.mcp.json +18 -0
- package/plugin/CLAUDE.md +143 -0
- package/plugin/bundle/dashboard/public/index.html +368 -0
- package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/plugin/bundle/mcp-server.mjs +8718 -0
- package/plugin/bundle/mcp-server.mjs.map +7 -0
- package/plugin/bundle/tdr-engine.mjs +50 -0
- package/plugin/bundle/tdr-engine.mjs.map +7 -0
- package/plugin/hooks/hooks.json +62 -0
- package/plugin/hooks/lib/crap-config.mjs +152 -0
- package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
- package/plugin/hooks/lib/hook-io.mjs +151 -0
- package/plugin/hooks/lib/quality-gate.mjs +329 -0
- package/plugin/hooks/lib/test-harness.mjs +152 -0
- package/plugin/hooks/post-tool-use.mjs +245 -0
- package/plugin/hooks/pre-tool-use.mjs +290 -0
- package/plugin/hooks/session-start.mjs +109 -0
- package/plugin/hooks/stop-quality-gate.mjs +226 -0
- package/plugin/package.json +18 -0
- package/plugin/skills/adopt/SKILL.md +74 -0
- package/plugin/skills/analyze/SKILL.md +77 -0
- package/plugin/skills/check-test/SKILL.md +50 -0
- package/plugin/skills/score/SKILL.md +31 -0
- package/scripts/bug-report.mjs +328 -0
- package/scripts/build-fast.mjs +130 -0
- package/scripts/bundle-plugin.mjs +74 -0
- package/scripts/doctor.mjs +320 -0
- package/scripts/install.mjs +192 -0
- package/scripts/lib/cli-ui.mjs +122 -0
- package/scripts/postinstall.mjs +127 -0
- package/scripts/run-tests.mjs +95 -0
- package/scripts/status.mjs +110 -0
- package/scripts/uninstall.mjs +72 -0
- package/src/adapters/bandit.ts +191 -0
- package/src/adapters/common.ts +133 -0
- package/src/adapters/eslint.ts +187 -0
- package/src/adapters/index.ts +78 -0
- package/src/adapters/semgrep.ts +150 -0
- package/src/adapters/stryker.ts +218 -0
- package/src/ast/cyclomatic.ts +131 -0
- package/src/ast/index.ts +33 -0
- package/src/ast/language-config.ts +231 -0
- package/src/ast/tree-sitter-engine.ts +385 -0
- package/src/config.ts +109 -0
- package/src/crap-config.ts +196 -0
- package/src/dashboard/public/index.html +368 -0
- package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/src/dashboard/server.ts +205 -0
- package/src/index.ts +696 -0
- package/src/metrics/crap.ts +101 -0
- package/src/metrics/index.ts +51 -0
- package/src/metrics/score.ts +329 -0
- package/src/metrics/tdr.ts +155 -0
- package/src/metrics/workspace-walker.ts +146 -0
- package/src/sarif/index.ts +31 -0
- package/src/sarif/sarif-builder.ts +139 -0
- package/src/sarif/sarif-store.ts +347 -0
- package/src/sarif/sarif-validator.ts +145 -0
- package/src/schemas/tool-schemas.ts +225 -0
- package/src/sdk.ts +110 -0
- package/src/tests/adapters/bandit.test.ts +111 -0
- package/src/tests/adapters/dispatch.test.ts +100 -0
- package/src/tests/adapters/eslint.test.ts +138 -0
- package/src/tests/adapters/semgrep.test.ts +125 -0
- package/src/tests/adapters/stryker.test.ts +103 -0
- package/src/tests/crap-config.test.ts +228 -0
- package/src/tests/crap.test.ts +59 -0
- package/src/tests/cyclomatic.test.ts +87 -0
- package/src/tests/dashboard-http.test.ts +108 -0
- package/src/tests/dashboard-integrity.test.ts +128 -0
- package/src/tests/integration/mcp-server.integration.test.ts +352 -0
- package/src/tests/pre-tool-use-hook.test.ts +178 -0
- package/src/tests/sarif-store.test.ts +241 -0
- package/src/tests/sarif-validator.test.ts +164 -0
- package/src/tests/score.test.ts +260 -0
- package/src/tests/skills-frontmatter.test.ts +172 -0
- package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
- package/src/tests/tdr.test.ts +86 -0
- package/src/tests/test-harness.test.ts +153 -0
- package/src/tests/workspace-guard.test.ts +111 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/test-harness.ts +158 -0
- package/src/workspace-guard.ts +64 -0
- 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
|
+
}
|