claude-crap 0.4.2 → 0.4.4

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-crap-plugin",
9
- "version": "0.4.2",
9
+ "version": "0.4.4",
10
10
  "dependencies": {
11
11
  "@fastify/static": "^8.0.3",
12
12
  "@modelcontextprotocol/sdk": "^1.0.4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for the claude-crap plugin bundle",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -9,12 +9,15 @@
9
9
  * user settings → plugin.json "options" → .mcp.json "env" → this file
10
10
  *
11
11
  * If any environment variable is missing or empty, a safe default is used,
12
- * but the loader NEVER invents stochastic values and NEVER performs I/O.
13
- * This module is the single source of truth for runtime configuration.
12
+ * but the loader NEVER invents stochastic values. This module is the
13
+ * single source of truth for runtime configuration.
14
14
  *
15
15
  * @module config
16
16
  */
17
17
 
18
+ import { execFileSync } from "node:child_process";
19
+ import { readlinkSync } from "node:fs";
20
+
18
21
  /**
19
22
  * Maintainability rating letter grades used throughout claude-crap.
20
23
  *
@@ -30,7 +33,7 @@ export type MaintainabilityRating = "A" | "B" | "C" | "D" | "E";
30
33
  * configuration at runtime — any change must go through a server restart.
31
34
  */
32
35
  export interface CrapConfig {
33
- /** Absolute path to the plugin root on disk. Defaults to `process.cwd()`. */
36
+ /** Absolute path to the user's workspace. Resolved via {@link discoverWorkspaceRoot}. */
34
37
  readonly pluginRoot: string;
35
38
  /** Directory (relative to the workspace) where consolidated SARIF reports are written. */
36
39
  readonly sarifOutputDir: string;
@@ -46,6 +49,112 @@ export interface CrapConfig {
46
49
  readonly dashboardPort: number;
47
50
  }
48
51
 
52
+ /**
53
+ * Detects an unexpanded `.mcp.json` variable template such as
54
+ * `${CLAUDE_PROJECT_DIR}`. Claude Code only expands `${CLAUDE_PLUGIN_ROOT}`
55
+ * inside `.mcp.json`; every other `${VAR}` is passed through verbatim and
56
+ * must NOT be treated as a real filesystem path.
57
+ */
58
+ function isLiteralVarTemplate(value: string | undefined): boolean {
59
+ if (value === undefined) return false;
60
+ return /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value.trim());
61
+ }
62
+
63
+ /**
64
+ * Normalize an environment variable that is expected to contain a path.
65
+ * Returns `undefined` if the value is missing, empty, or an unexpanded
66
+ * `${...}` template. Any non-empty concrete string is returned as-is.
67
+ */
68
+ function sanitizeEnvPath(value: string | undefined): string | undefined {
69
+ if (value === undefined || value === "") return undefined;
70
+ if (isLiteralVarTemplate(value)) return undefined;
71
+ return value;
72
+ }
73
+
74
+ /**
75
+ * Read the current working directory of the parent process. Claude Code
76
+ * spawns MCP servers with its own cwd set to the user's workspace, so the
77
+ * parent's cwd is the most reliable fallback when `CLAUDE_PROJECT_DIR` is
78
+ * not inherited (e.g. because Claude Code only exports it for hooks).
79
+ *
80
+ * Returns `undefined` on any platform or failure mode the probe cannot
81
+ * handle — callers must be prepared for a missing result.
82
+ */
83
+ function readParentCwdDefault(): string | undefined {
84
+ try {
85
+ const ppid = process.ppid;
86
+ if (!ppid || ppid === 0) return undefined;
87
+
88
+ if (process.platform === "linux") {
89
+ // /proc/<pid>/cwd is a symlink to the process's cwd.
90
+ return readlinkSync(`/proc/${ppid}/cwd`);
91
+ }
92
+
93
+ if (process.platform === "darwin") {
94
+ // `lsof -a -p <pid> -d cwd -F n` emits a single line starting with
95
+ // `n<path>` for the cwd file descriptor. `-F` keeps the output
96
+ // machine-readable.
97
+ const output = execFileSync(
98
+ "lsof",
99
+ ["-a", "-p", String(ppid), "-d", "cwd", "-F", "n"],
100
+ {
101
+ encoding: "utf8",
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ timeout: 2000,
104
+ },
105
+ );
106
+ const match = output.match(/^n(.+)$/m);
107
+ return match?.[1];
108
+ }
109
+
110
+ // Windows and other platforms: no reliable no-dep probe.
111
+ return undefined;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Parameters accepted by {@link discoverWorkspaceRoot}. The only field is
119
+ * an injectable `readParentCwd` implementation so that tests can pin the
120
+ * fallback behavior without spawning `lsof` or reading `/proc`.
121
+ */
122
+ export interface DiscoverWorkspaceOptions {
123
+ readParentCwd?: () => string | undefined;
124
+ }
125
+
126
+ /**
127
+ * Resolve the user's workspace directory. Strategy, in strict priority
128
+ * order:
129
+ *
130
+ * 1. `CLAUDE_PROJECT_DIR` (sanitized — ignored if it's `${...}`)
131
+ * 2. `CLAUDE_CRAP_PLUGIN_ROOT` (sanitized — legacy explicit override)
132
+ * 3. Parent process cwd (Claude Code's cwd = the workspace)
133
+ * 4. `process.cwd()` (last-resort fallback; usually wrong for
134
+ * MCP servers because Claude Code sets
135
+ * cwd to the plugin cache directory)
136
+ *
137
+ * This function NEVER returns an unexpanded `${...}` template; any source
138
+ * that contains one is skipped as if it were unset.
139
+ *
140
+ * @param options Injection points for tests.
141
+ * @returns A concrete filesystem path.
142
+ */
143
+ export function discoverWorkspaceRoot(options: DiscoverWorkspaceOptions = {}): string {
144
+ const readParentCwd = options.readParentCwd ?? readParentCwdDefault;
145
+
146
+ const fromProjectDir = sanitizeEnvPath(process.env.CLAUDE_PROJECT_DIR);
147
+ if (fromProjectDir) return fromProjectDir;
148
+
149
+ const fromPluginRoot = sanitizeEnvPath(process.env.CLAUDE_CRAP_PLUGIN_ROOT);
150
+ if (fromPluginRoot) return fromPluginRoot;
151
+
152
+ const fromParent = sanitizeEnvPath(readParentCwd());
153
+ if (fromParent) return fromParent;
154
+
155
+ return process.cwd();
156
+ }
157
+
49
158
  /**
50
159
  * Parse a numeric environment variable, falling back to `fallback` when the
51
160
  * variable is undefined or empty. Throws if the value is present but not a
@@ -98,7 +207,7 @@ function parseRating(raw: string | undefined, fallback: MaintainabilityRating):
98
207
  */
99
208
  export function loadConfig(): CrapConfig {
100
209
  return {
101
- pluginRoot: process.env.CLAUDE_CRAP_PLUGIN_ROOT ?? process.cwd(),
210
+ pluginRoot: discoverWorkspaceRoot(),
102
211
  sarifOutputDir: process.env.CLAUDE_CRAP_SARIF_OUTPUT_DIR ?? ".claude-crap/reports",
103
212
  crapThreshold: parseNumber("CLAUDE_CRAP_CRAP_THRESHOLD", process.env.CLAUDE_CRAP_CRAP_THRESHOLD, 30),
104
213
  cyclomaticMax: parseNumber("CLAUDE_CRAP_CYCLOMATIC_MAX", process.env.CLAUDE_CRAP_CYCLOMATIC_MAX, 15),
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Characterization tests for the MCP server config loader.
3
+ *
4
+ * `loadConfig()` resolves `pluginRoot` from three env vars with
5
+ * strict priority:
6
+ *
7
+ * 1. `CLAUDE_PROJECT_DIR` — set by Claude Code to the workspace
8
+ * 2. `CLAUDE_CRAP_PLUGIN_ROOT` — legacy explicit override
9
+ * 3. `process.cwd()` — last-resort fallback
10
+ *
11
+ * These tests pin the priority chain and the numeric/rating parsers
12
+ * so that regressions in workspace resolution are caught before they
13
+ * ship a bundle that writes SARIF reports into the plugin cache.
14
+ *
15
+ * @module tests/config.test
16
+ */
17
+
18
+ import { describe, it, beforeEach, afterEach } from "node:test";
19
+ import assert from "node:assert/strict";
20
+
21
+ import { loadConfig, discoverWorkspaceRoot } from "../config.js";
22
+
23
+ /* ── env snapshot / restore ─────────────────────────────────────── */
24
+
25
+ const ENV_KEYS = [
26
+ "CLAUDE_PROJECT_DIR",
27
+ "CLAUDE_CRAP_PLUGIN_ROOT",
28
+ "CLAUDE_CRAP_SARIF_OUTPUT_DIR",
29
+ "CLAUDE_CRAP_CRAP_THRESHOLD",
30
+ "CLAUDE_CRAP_CYCLOMATIC_MAX",
31
+ "CLAUDE_CRAP_TDR_MAX_RATING",
32
+ "CLAUDE_CRAP_MINUTES_PER_LOC",
33
+ "CLAUDE_CRAP_DASHBOARD_PORT",
34
+ ] as const;
35
+
36
+ type Snapshot = Map<string, string | undefined>;
37
+
38
+ function snapshotEnv(): Snapshot {
39
+ const snap = new Map<string, string | undefined>();
40
+ for (const key of ENV_KEYS) snap.set(key, process.env[key]);
41
+ return snap;
42
+ }
43
+
44
+ function restoreEnv(snap: Snapshot): void {
45
+ for (const [key, val] of snap) {
46
+ if (val === undefined) delete process.env[key];
47
+ else process.env[key] = val;
48
+ }
49
+ }
50
+
51
+ function clearConfigEnv(): void {
52
+ for (const key of ENV_KEYS) delete process.env[key];
53
+ }
54
+
55
+ /* ── tests ──────────────────────────────────────────────────────── */
56
+
57
+ describe("loadConfig — pluginRoot priority chain", () => {
58
+ let saved: Snapshot;
59
+
60
+ beforeEach(() => {
61
+ saved = snapshotEnv();
62
+ clearConfigEnv();
63
+ });
64
+
65
+ afterEach(() => {
66
+ restoreEnv(saved);
67
+ });
68
+
69
+ it("falls back to process.cwd() when no env vars are set", () => {
70
+ const cfg = loadConfig();
71
+ assert.equal(cfg.pluginRoot, process.cwd());
72
+ });
73
+
74
+ it("CLAUDE_CRAP_PLUGIN_ROOT wins over process.cwd()", () => {
75
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "/explicit/plugin/root";
76
+ const cfg = loadConfig();
77
+ assert.equal(cfg.pluginRoot, "/explicit/plugin/root");
78
+ });
79
+
80
+ it("CLAUDE_PROJECT_DIR wins over CLAUDE_CRAP_PLUGIN_ROOT", () => {
81
+ process.env.CLAUDE_PROJECT_DIR = "/workspace/project";
82
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "/explicit/plugin/root";
83
+ const cfg = loadConfig();
84
+ assert.equal(cfg.pluginRoot, "/workspace/project");
85
+ });
86
+
87
+ it("CLAUDE_PROJECT_DIR alone resolves correctly", () => {
88
+ process.env.CLAUDE_PROJECT_DIR = "/workspace/project";
89
+ const cfg = loadConfig();
90
+ assert.equal(cfg.pluginRoot, "/workspace/project");
91
+ });
92
+ });
93
+
94
+ describe("loadConfig — defensive ${...} literal detection", () => {
95
+ // Claude Code only expands ${CLAUDE_PLUGIN_ROOT} in .mcp.json — any
96
+ // other ${VAR} is passed through verbatim. We must never treat such
97
+ // a literal template as a real path.
98
+ let saved: Snapshot;
99
+
100
+ beforeEach(() => {
101
+ saved = snapshotEnv();
102
+ clearConfigEnv();
103
+ });
104
+
105
+ afterEach(() => {
106
+ restoreEnv(saved);
107
+ });
108
+
109
+ it("ignores literal ${CLAUDE_PROJECT_DIR} and falls through", () => {
110
+ process.env.CLAUDE_PROJECT_DIR = "${CLAUDE_PROJECT_DIR}";
111
+ const cfg = loadConfig();
112
+ assert.notEqual(cfg.pluginRoot, "${CLAUDE_PROJECT_DIR}");
113
+ // Falls through to CLAUDE_CRAP_PLUGIN_ROOT or the last-resort fallback.
114
+ });
115
+
116
+ it("ignores literal ${CLAUDE_CRAP_PLUGIN_ROOT} and falls through", () => {
117
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "${CLAUDE_CRAP_PLUGIN_ROOT}";
118
+ const cfg = loadConfig();
119
+ assert.notEqual(cfg.pluginRoot, "${CLAUDE_CRAP_PLUGIN_ROOT}");
120
+ });
121
+
122
+ it("ignores literal ${SOMETHING_ELSE} placeholder", () => {
123
+ process.env.CLAUDE_PROJECT_DIR = "${NOT_A_REAL_VAR}";
124
+ const cfg = loadConfig();
125
+ assert.notEqual(cfg.pluginRoot, "${NOT_A_REAL_VAR}");
126
+ });
127
+
128
+ it("a literal CLAUDE_PROJECT_DIR falls through to a valid CLAUDE_CRAP_PLUGIN_ROOT", () => {
129
+ process.env.CLAUDE_PROJECT_DIR = "${CLAUDE_PROJECT_DIR}";
130
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "/explicit/plugin/root";
131
+ const cfg = loadConfig();
132
+ assert.equal(cfg.pluginRoot, "/explicit/plugin/root");
133
+ });
134
+
135
+ it("treats an empty CLAUDE_PROJECT_DIR as unset", () => {
136
+ process.env.CLAUDE_PROJECT_DIR = "";
137
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "/explicit/plugin/root";
138
+ const cfg = loadConfig();
139
+ assert.equal(cfg.pluginRoot, "/explicit/plugin/root");
140
+ });
141
+ });
142
+
143
+ describe("discoverWorkspaceRoot — injectable parent-cwd fallback", () => {
144
+ // The real readParentCwd implementation reads the parent process's
145
+ // cwd via lsof/proc. For tests we inject a stub so we can pin the
146
+ // behavior without spawning lsof or depending on the host OS.
147
+ let saved: Snapshot;
148
+
149
+ beforeEach(() => {
150
+ saved = snapshotEnv();
151
+ clearConfigEnv();
152
+ });
153
+
154
+ afterEach(() => {
155
+ restoreEnv(saved);
156
+ });
157
+
158
+ it("returns env CLAUDE_PROJECT_DIR before consulting parent cwd", () => {
159
+ process.env.CLAUDE_PROJECT_DIR = "/workspace/project";
160
+ const root = discoverWorkspaceRoot({
161
+ readParentCwd: () => "/should/not/be/used",
162
+ });
163
+ assert.equal(root, "/workspace/project");
164
+ });
165
+
166
+ it("falls through literal ${...} and uses injected parent cwd", () => {
167
+ process.env.CLAUDE_PROJECT_DIR = "${CLAUDE_PROJECT_DIR}";
168
+ const root = discoverWorkspaceRoot({
169
+ readParentCwd: () => "/real/workspace",
170
+ });
171
+ assert.equal(root, "/real/workspace");
172
+ });
173
+
174
+ it("uses parent cwd when no env vars are set", () => {
175
+ const root = discoverWorkspaceRoot({
176
+ readParentCwd: () => "/discovered/via/parent",
177
+ });
178
+ assert.equal(root, "/discovered/via/parent");
179
+ });
180
+
181
+ it("falls back to process.cwd() when parent cwd is unavailable", () => {
182
+ const root = discoverWorkspaceRoot({
183
+ readParentCwd: () => undefined,
184
+ });
185
+ assert.equal(root, process.cwd());
186
+ });
187
+
188
+ it("ignores literal ${...} from parent cwd as well", () => {
189
+ const root = discoverWorkspaceRoot({
190
+ readParentCwd: () => "${something}",
191
+ });
192
+ assert.equal(root, process.cwd());
193
+ });
194
+
195
+ it("CLAUDE_CRAP_PLUGIN_ROOT wins over parent cwd", () => {
196
+ process.env.CLAUDE_CRAP_PLUGIN_ROOT = "/explicit";
197
+ const root = discoverWorkspaceRoot({
198
+ readParentCwd: () => "/parent/cwd",
199
+ });
200
+ assert.equal(root, "/explicit");
201
+ });
202
+ });
203
+
204
+ describe("loadConfig — defaults", () => {
205
+ let saved: Snapshot;
206
+
207
+ beforeEach(() => {
208
+ saved = snapshotEnv();
209
+ clearConfigEnv();
210
+ });
211
+
212
+ afterEach(() => {
213
+ restoreEnv(saved);
214
+ });
215
+
216
+ it("sarifOutputDir defaults to .claude-crap/reports", () => {
217
+ assert.equal(loadConfig().sarifOutputDir, ".claude-crap/reports");
218
+ });
219
+
220
+ it("crapThreshold defaults to 30", () => {
221
+ assert.equal(loadConfig().crapThreshold, 30);
222
+ });
223
+
224
+ it("cyclomaticMax defaults to 15", () => {
225
+ assert.equal(loadConfig().cyclomaticMax, 15);
226
+ });
227
+
228
+ it("tdrMaxRating defaults to C", () => {
229
+ assert.equal(loadConfig().tdrMaxRating, "C");
230
+ });
231
+
232
+ it("minutesPerLoc defaults to 30", () => {
233
+ assert.equal(loadConfig().minutesPerLoc, 30);
234
+ });
235
+
236
+ it("dashboardPort defaults to 5117", () => {
237
+ assert.equal(loadConfig().dashboardPort, 5117);
238
+ });
239
+ });
240
+
241
+ describe("loadConfig — numeric parsing", () => {
242
+ let saved: Snapshot;
243
+
244
+ beforeEach(() => {
245
+ saved = snapshotEnv();
246
+ clearConfigEnv();
247
+ });
248
+
249
+ afterEach(() => {
250
+ restoreEnv(saved);
251
+ });
252
+
253
+ it("reads CLAUDE_CRAP_CRAP_THRESHOLD from env", () => {
254
+ process.env.CLAUDE_CRAP_CRAP_THRESHOLD = "50";
255
+ assert.equal(loadConfig().crapThreshold, 50);
256
+ });
257
+
258
+ it("throws on non-numeric CLAUDE_CRAP_CRAP_THRESHOLD", () => {
259
+ process.env.CLAUDE_CRAP_CRAP_THRESHOLD = "not-a-number";
260
+ assert.throws(() => loadConfig(), /not a finite number/);
261
+ });
262
+
263
+ it("ignores empty string and falls back to default", () => {
264
+ process.env.CLAUDE_CRAP_CRAP_THRESHOLD = "";
265
+ assert.equal(loadConfig().crapThreshold, 30);
266
+ });
267
+ });
268
+
269
+ describe("loadConfig — rating parsing", () => {
270
+ let saved: Snapshot;
271
+
272
+ beforeEach(() => {
273
+ saved = snapshotEnv();
274
+ clearConfigEnv();
275
+ });
276
+
277
+ afterEach(() => {
278
+ restoreEnv(saved);
279
+ });
280
+
281
+ it("accepts lowercase rating", () => {
282
+ process.env.CLAUDE_CRAP_TDR_MAX_RATING = "a";
283
+ assert.equal(loadConfig().tdrMaxRating, "A");
284
+ });
285
+
286
+ it("accepts padded rating", () => {
287
+ process.env.CLAUDE_CRAP_TDR_MAX_RATING = " B ";
288
+ assert.equal(loadConfig().tdrMaxRating, "B");
289
+ });
290
+
291
+ it("throws on invalid rating letter", () => {
292
+ process.env.CLAUDE_CRAP_TDR_MAX_RATING = "F";
293
+ assert.throws(() => loadConfig(), /must be one of A, B, C, D, E/);
294
+ });
295
+ });