claude-crap 0.3.7 → 0.4.0

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 (100) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +74 -7
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +1 -1
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/adapters/dart-analyzer.d.ts +41 -0
  8. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  9. package/dist/adapters/dart-analyzer.js +120 -0
  10. package/dist/adapters/dart-analyzer.js.map +1 -0
  11. package/dist/adapters/dotnet-format.d.ts +35 -0
  12. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  13. package/dist/adapters/dotnet-format.js +96 -0
  14. package/dist/adapters/dotnet-format.js.map +1 -0
  15. package/dist/adapters/index.d.ts +2 -0
  16. package/dist/adapters/index.d.ts.map +1 -1
  17. package/dist/adapters/index.js +8 -0
  18. package/dist/adapters/index.js.map +1 -1
  19. package/dist/crap-config.d.ts +4 -0
  20. package/dist/crap-config.d.ts.map +1 -1
  21. package/dist/crap-config.js +51 -28
  22. package/dist/crap-config.js.map +1 -1
  23. package/dist/dashboard/file-detail.d.ts.map +1 -1
  24. package/dist/dashboard/file-detail.js.map +1 -1
  25. package/dist/dashboard/server.d.ts +2 -0
  26. package/dist/dashboard/server.d.ts.map +1 -1
  27. package/dist/dashboard/server.js +7 -12
  28. package/dist/dashboard/server.js.map +1 -1
  29. package/dist/index.js +89 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/workspace-walker.d.ts +4 -1
  32. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  33. package/dist/metrics/workspace-walker.js +12 -28
  34. package/dist/metrics/workspace-walker.js.map +1 -1
  35. package/dist/monorepo/project-map.d.ts +112 -0
  36. package/dist/monorepo/project-map.d.ts.map +1 -0
  37. package/dist/monorepo/project-map.js +384 -0
  38. package/dist/monorepo/project-map.js.map +1 -0
  39. package/dist/scanner/auto-scan.d.ts +1 -0
  40. package/dist/scanner/auto-scan.d.ts.map +1 -1
  41. package/dist/scanner/auto-scan.js +14 -5
  42. package/dist/scanner/auto-scan.js.map +1 -1
  43. package/dist/scanner/bootstrap.d.ts +1 -1
  44. package/dist/scanner/bootstrap.d.ts.map +1 -1
  45. package/dist/scanner/bootstrap.js +15 -1
  46. package/dist/scanner/bootstrap.js.map +1 -1
  47. package/dist/scanner/complexity-scanner.d.ts +2 -0
  48. package/dist/scanner/complexity-scanner.d.ts.map +1 -1
  49. package/dist/scanner/complexity-scanner.js +11 -26
  50. package/dist/scanner/complexity-scanner.js.map +1 -1
  51. package/dist/scanner/detector.d.ts +24 -4
  52. package/dist/scanner/detector.d.ts.map +1 -1
  53. package/dist/scanner/detector.js +110 -10
  54. package/dist/scanner/detector.js.map +1 -1
  55. package/dist/scanner/runner.d.ts +4 -1
  56. package/dist/scanner/runner.d.ts.map +1 -1
  57. package/dist/scanner/runner.js +25 -3
  58. package/dist/scanner/runner.js.map +1 -1
  59. package/dist/schemas/tool-schemas.d.ts +16 -1
  60. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  61. package/dist/schemas/tool-schemas.js +16 -1
  62. package/dist/schemas/tool-schemas.js.map +1 -1
  63. package/dist/shared/exclusions.d.ts +53 -0
  64. package/dist/shared/exclusions.d.ts.map +1 -0
  65. package/dist/shared/exclusions.js +126 -0
  66. package/dist/shared/exclusions.js.map +1 -0
  67. package/package.json +3 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/CLAUDE.md +37 -0
  70. package/plugin/bundle/mcp-server.mjs +762 -144
  71. package/plugin/bundle/mcp-server.mjs.map +4 -4
  72. package/plugin/package-lock.json +15 -2
  73. package/plugin/package.json +2 -1
  74. package/scripts/bundle-plugin.mjs +2 -1
  75. package/src/adapters/common.ts +1 -1
  76. package/src/adapters/dart-analyzer.ts +161 -0
  77. package/src/adapters/dotnet-format.ts +125 -0
  78. package/src/adapters/index.ts +8 -0
  79. package/src/crap-config.ts +78 -18
  80. package/src/dashboard/file-detail.ts +0 -2
  81. package/src/dashboard/server.ts +9 -10
  82. package/src/index.ts +103 -5
  83. package/src/metrics/workspace-walker.ts +15 -27
  84. package/src/monorepo/project-map.ts +476 -0
  85. package/src/scanner/auto-scan.ts +17 -6
  86. package/src/scanner/bootstrap.ts +18 -1
  87. package/src/scanner/complexity-scanner.ts +15 -26
  88. package/src/scanner/detector.ts +119 -10
  89. package/src/scanner/runner.ts +25 -2
  90. package/src/schemas/tool-schemas.ts +17 -1
  91. package/src/shared/exclusions.ts +156 -0
  92. package/src/tests/adapters/dispatch.test.ts +2 -2
  93. package/src/tests/auto-scan.test.ts +2 -2
  94. package/src/tests/boot-monorepo.test.ts +804 -0
  95. package/src/tests/boot-scanner-detection.test.ts +692 -0
  96. package/src/tests/boot-single-project.test.ts +780 -0
  97. package/src/tests/exclusions.test.ts +117 -0
  98. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  99. package/src/tests/project-map.test.ts +302 -0
  100. package/src/tests/scanner-detector.test.ts +31 -11
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ import { findTestFile } from "./tools/test-harness.js";
59
59
  import { resolveWithinWorkspace } from "./workspace-guard.js";
60
60
  import { autoScan } from "./scanner/auto-scan.js";
61
61
  import { bootstrapScanner } from "./scanner/bootstrap.js";
62
+ import { discoverProjectMap, persistProjectMap, type ProjectMap } from "./monorepo/project-map.js";
62
63
  import {
63
64
  autoScanSchema,
64
65
  bootstrapScannerSchema,
@@ -67,6 +68,7 @@ import {
67
68
  analyzeFileAstSchema,
68
69
  ingestSarifSchema,
69
70
  ingestScannerOutputSchema,
71
+ listProjectsSchema,
70
72
  requireTestHarnessSchema,
71
73
  scoreProjectSchema,
72
74
  } from "./schemas/tool-schemas.js";
@@ -93,6 +95,23 @@ async function main(): Promise<void> {
93
95
  "claude-crap MCP server starting",
94
96
  );
95
97
 
98
+ // Load user-defined exclusions and projectDirs from .claude-crap.json (non-fatal).
99
+ let userExclusions: ReadonlyArray<string> = [];
100
+ let userProjectDirs: ReadonlyArray<string> = [];
101
+ try {
102
+ const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
103
+ userExclusions = crapConfig.exclude;
104
+ userProjectDirs = crapConfig.projectDirs;
105
+ if (userExclusions.length > 0) {
106
+ logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
107
+ }
108
+ if (userProjectDirs.length > 0) {
109
+ logger.info({ projectDirs: userProjectDirs }, "user projectDirs loaded from .claude-crap.json");
110
+ }
111
+ } catch {
112
+ // Non-fatal — use empty exclusions.
113
+ }
114
+
96
115
  // Long-lived engines. Created once at boot and reused for every call.
97
116
  const astEngine = new TreeSitterEngine();
98
117
  const sarifStore = new SarifStore({
@@ -105,6 +124,39 @@ async function main(): Promise<void> {
105
124
  "SARIF store ready",
106
125
  );
107
126
 
127
+ // Discover monorepo project map (non-fatal).
128
+ let projectMap: ProjectMap | null = null;
129
+ try {
130
+ projectMap = await discoverProjectMap(config.pluginRoot, { projectDirs: userProjectDirs });
131
+ if (projectMap.isMonorepo) {
132
+ logger.info(
133
+ { projects: projectMap.projects.map((p) => `${p.name}(${p.type})`), count: projectMap.projects.length },
134
+ "monorepo project map discovered",
135
+ );
136
+ await persistProjectMap(projectMap, config.pluginRoot);
137
+
138
+ // If any JS/TS sub-projects need ESLint and it's not available,
139
+ // run bootstrap at the monorepo root to auto-install it. In
140
+ // monorepos, ESLint is hoisted to the root node_modules.
141
+ const needsEslint = projectMap.projects.some(
142
+ (p) => (p.type === "typescript" || p.type === "javascript") && !p.scannerAvailable,
143
+ );
144
+ if (needsEslint) {
145
+ logger.info("monorepo: JS/TS projects detected but ESLint not installed — bootstrapping");
146
+ try {
147
+ await bootstrapScanner(config.pluginRoot, sarifStore, logger);
148
+ // Re-discover after install so scannerAvailable reflects reality
149
+ projectMap = await discoverProjectMap(config.pluginRoot, { projectDirs: userProjectDirs });
150
+ await persistProjectMap(projectMap, config.pluginRoot);
151
+ } catch (err) {
152
+ logger.warn({ err: (err as Error).message }, "monorepo ESLint bootstrap failed");
153
+ }
154
+ }
155
+ }
156
+ } catch (err) {
157
+ logger.warn({ err: (err as Error).message }, "project map discovery failed");
158
+ }
159
+
108
160
  // Try to start the local Vue.js dashboard. Failures here are
109
161
  // intentionally non-fatal — the MCP server still works without it.
110
162
  let dashboard: DashboardHandle | null = null;
@@ -112,9 +164,10 @@ async function main(): Promise<void> {
112
164
  dashboard = await startDashboard({
113
165
  config,
114
166
  sarifStore,
115
- workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
167
+ workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions }),
116
168
  logger,
117
169
  astEngine,
170
+ exclude: userExclusions,
118
171
  });
119
172
  } catch (err) {
120
173
  logger.warn(
@@ -222,6 +275,11 @@ async function main(): Promise<void> {
222
275
  "Detect project type, install the right scanner (ESLint for JS/TS, Bandit for Python, Semgrep for Java/C#), create minimal config, and run auto_scan to verify.",
223
276
  inputSchema: bootstrapScannerSchema,
224
277
  },
278
+ {
279
+ name: "list_projects",
280
+ description: "List all discovered sub-projects in the workspace. In a monorepo, returns each sub-project with its type, path, and recommended scanner.",
281
+ inputSchema: listProjectsSchema,
282
+ },
225
283
  ],
226
284
  }));
227
285
 
@@ -231,10 +289,20 @@ async function main(): Promise<void> {
231
289
  // The MCP SDK has already validated `args` against the tool's JSON
232
290
  // Schema by the time this handler runs, so we cast to the expected
233
291
  // shape without re-validating. Each branch delegates to a pure engine.
292
+ // Tool dispatch is split across two functions to keep cyclomatic
293
+ // complexity within the configured threshold (15) as the tool count
294
+ // grows. Each function handles a subset of tools.
234
295
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
296
  const { name, arguments: args } = request.params;
236
297
  logger.info({ tool: name }, "Tool call received");
298
+ return handleToolCall(name, args);
299
+ });
237
300
 
301
+ /** Dispatch a tool call to the correct handler. */
302
+ async function handleToolCall(
303
+ name: string,
304
+ args: Record<string, unknown> | undefined,
305
+ ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
238
306
  switch (name) {
239
307
  case "compute_crap": {
240
308
  const typed = args as {
@@ -330,12 +398,21 @@ async function main(): Promise<void> {
330
398
  }
331
399
 
332
400
  case "score_project": {
333
- const typed = (args ?? {}) as { format?: "markdown" | "json" | "both" };
401
+ const typed = (args ?? {}) as { format?: "markdown" | "json" | "both"; scope?: string };
334
402
  const format = typed.format ?? "both";
403
+ // Resolve scope to a workspace subdirectory
404
+ let scoreRoot = config.pluginRoot;
405
+ if (typed.scope && projectMap) {
406
+ const project = projectMap.projects.find((p) => p.name === typed.scope);
407
+ if (project) {
408
+ const { join } = await import("node:path");
409
+ scoreRoot = join(config.pluginRoot, project.path);
410
+ }
411
+ }
335
412
  try {
336
- const workspace = await estimateWorkspaceLoc(config.pluginRoot);
413
+ const workspace = await estimateWorkspaceLoc(scoreRoot, { exclude: userExclusions });
337
414
  const score: ProjectScore = computeProjectScore({
338
- workspaceRoot: config.pluginRoot,
415
+ workspaceRoot: scoreRoot,
339
416
  minutesPerLoc: config.minutesPerLoc,
340
417
  tdrMaxRating: config.tdrMaxRating,
341
418
  workspace: { physicalLoc: workspace.physicalLoc, fileCount: workspace.fileCount },
@@ -585,6 +662,7 @@ async function main(): Promise<void> {
585
662
  const result = await autoScan(config.pluginRoot, sarifStore, logger, {
586
663
  engine: astEngine,
587
664
  cyclomaticMax: config.cyclomaticMax,
665
+ exclude: userExclusions,
588
666
  });
589
667
  const markdown = renderAutoScanMarkdown(result);
590
668
  return {
@@ -611,10 +689,29 @@ async function main(): Promise<void> {
611
689
  }
612
690
  }
613
691
 
692
+ case "list_projects": {
693
+ return {
694
+ content: [
695
+ {
696
+ type: "text",
697
+ text: JSON.stringify(
698
+ {
699
+ tool: "list_projects",
700
+ isMonorepo: projectMap?.isMonorepo ?? false,
701
+ projects: projectMap?.projects ?? [],
702
+ },
703
+ null,
704
+ 2,
705
+ ),
706
+ },
707
+ ],
708
+ };
709
+ }
710
+
614
711
  default:
615
712
  throw new Error(`[claude-crap] Unknown tool: ${name}`);
616
713
  }
617
- });
714
+ }
618
715
 
619
716
  // ------------------------------------------------------------------
620
717
  // Resources — topology and reports
@@ -676,6 +773,7 @@ async function main(): Promise<void> {
676
773
  autoScan(config.pluginRoot, sarifStore, logger, {
677
774
  engine: astEngine,
678
775
  cyclomaticMax: config.cyclomaticMax,
776
+ exclude: userExclusions,
679
777
  })
680
778
  .then((result) => {
681
779
  const scanners = result.results
@@ -15,7 +15,9 @@
15
15
  */
16
16
 
17
17
  import { promises as fs } from "node:fs";
18
- import { join } from "node:path";
18
+ import { join, relative } from "node:path";
19
+
20
+ import { createExclusionFilter } from "../shared/exclusions.js";
19
21
 
20
22
  /**
21
23
  * Result returned by {@link estimateWorkspaceLoc}.
@@ -29,26 +31,9 @@ export interface WorkspaceWalkResult {
29
31
  readonly truncated: boolean;
30
32
  }
31
33
 
32
- /**
33
- * Directories that should never contribute to the LOC count. Dependency
34
- * caches, build artifacts, VCS metadata, claude-crap's own state.
35
- */
36
- const SKIP_DIRS: ReadonlySet<string> = new Set([
37
- "node_modules",
38
- ".git",
39
- "dist",
40
- "build",
41
- "out",
42
- "target",
43
- ".venv",
44
- "venv",
45
- "__pycache__",
46
- ".cache",
47
- ".next",
48
- ".nuxt",
49
- ".claude-crap",
50
- ".codesight",
51
- ]);
34
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
35
+ // The createExclusionFilter() factory is called once per walk with
36
+ // optional user-defined patterns from .claude-crap.json.
52
37
 
53
38
  /**
54
39
  * Extensions the walker treats as "code". Anything else is ignored,
@@ -91,9 +76,14 @@ export const MAX_FILES_WALKED = 20_000;
91
76
  * (which is tiny and contains the manifest).
92
77
  *
93
78
  * @param workspaceRoot Absolute path to the workspace root.
79
+ * @param options Optional settings including user-defined exclusion patterns.
94
80
  * @returns A {@link WorkspaceWalkResult} snapshot.
95
81
  */
96
- export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<WorkspaceWalkResult> {
82
+ export async function estimateWorkspaceLoc(
83
+ workspaceRoot: string,
84
+ options?: { exclude?: ReadonlyArray<string> },
85
+ ): Promise<WorkspaceWalkResult> {
86
+ const filter = createExclusionFilter(options?.exclude);
97
87
  let physicalLoc = 0;
98
88
  let fileCount = 0;
99
89
  let truncated = false;
@@ -108,11 +98,9 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
108
98
  }
109
99
  for (const entry of entries) {
110
100
  if (truncated) return;
111
- // Skip hidden files except the plugin manifest dir.
112
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
113
101
  const full = join(dir, entry.name);
114
102
  if (entry.isDirectory()) {
115
- if (SKIP_DIRS.has(entry.name)) continue;
103
+ if (filter.shouldSkipDir(entry.name)) continue;
116
104
  await walk(full);
117
105
  continue;
118
106
  }
@@ -122,6 +110,8 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
122
110
  if (dot < 0) continue;
123
111
  const ext = lower.substring(dot);
124
112
  if (!CODE_EXTENSIONS.has(ext)) continue;
113
+ const relPath = relative(workspaceRoot, full);
114
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
125
115
  fileCount += 1;
126
116
  if (fileCount > MAX_FILES_WALKED) {
127
117
  truncated = true;
@@ -130,8 +120,6 @@ export async function estimateWorkspaceLoc(workspaceRoot: string): Promise<Works
130
120
  try {
131
121
  const content = await fs.readFile(full, "utf8");
132
122
  if (content.length > 0) {
133
- // Subtract 1 for trailing newline, matching what most editors
134
- // report as the file's line count.
135
123
  const lines = content.split(/\r?\n/).length;
136
124
  physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
137
125
  }