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
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-crap-plugin",
9
- "version": "0.3.7",
9
+ "version": "0.4.0",
10
10
  "dependencies": {
11
11
  "@fastify/static": "^8.0.3",
12
12
  "@modelcontextprotocol/sdk": "^1.0.4",
13
13
  "fastify": "^5.2.0",
14
+ "picomatch": "^2.3.0",
14
15
  "pino": "^9.5.0",
15
16
  "tree-sitter-wasms": "^0.1.12",
16
17
  "web-tree-sitter": "^0.24.4"
@@ -2069,6 +2070,18 @@
2069
2070
  "url": "https://opencollective.com/express"
2070
2071
  }
2071
2072
  },
2073
+ "node_modules/picomatch": {
2074
+ "version": "2.3.2",
2075
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
2076
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
2077
+ "license": "MIT",
2078
+ "engines": {
2079
+ "node": ">=8.6"
2080
+ },
2081
+ "funding": {
2082
+ "url": "https://github.com/sponsors/jonschlinkert"
2083
+ }
2084
+ },
2072
2085
  "node_modules/pino": {
2073
2086
  "version": "9.14.0",
2074
2087
  "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for the claude-crap plugin bundle",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "fastify": "^5.2.0",
11
11
  "pino": "^9.5.0",
12
12
  "tree-sitter-wasms": "^0.1.12",
13
+ "picomatch": "^2.3.0",
13
14
  "web-tree-sitter": "^0.24.4"
14
15
  },
15
16
  "engines": {
@@ -34,7 +34,8 @@ async function main() {
34
34
  "pino",
35
35
  "fastify",
36
36
  "@fastify/static",
37
- "@modelcontextprotocol/sdk"
37
+ "@modelcontextprotocol/sdk",
38
+ "picomatch",
38
39
  ],
39
40
  banner: {
40
41
  js: "// Generated by scripts/bundle-plugin.mjs — DO NOT EDIT",
@@ -23,7 +23,7 @@ import type { SarifLevel } from "../sarif/sarif-builder.js";
23
23
  * `ingest_scanner_output` MCP tool uses this as its `enum` constraint,
24
24
  * so keeping it narrow prevents drift.
25
25
  */
26
- export const KNOWN_SCANNERS = ["semgrep", "eslint", "bandit", "stryker"] as const;
26
+ export const KNOWN_SCANNERS = ["semgrep", "eslint", "bandit", "stryker", "dart_analyze", "dotnet_format"] as const;
27
27
 
28
28
  /**
29
29
  * Union of supported scanner identifiers.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Adapter: `dart analyze --format=json` → SARIF 2.1.0.
3
+ *
4
+ * The Dart analyzer emits JSON with this shape:
5
+ *
6
+ * {
7
+ * "version": 1,
8
+ * "diagnostics": [
9
+ * {
10
+ * "code": "unused_import",
11
+ * "severity": "WARNING",
12
+ * "type": "STATIC_WARNING",
13
+ * "location": {
14
+ * "file": "/absolute/path/to/file.dart",
15
+ * "range": {
16
+ * "start": { "offset": 7, "line": 1, "column": 8 },
17
+ * "end": { "offset": 16, "line": 1, "column": 17 }
18
+ * }
19
+ * },
20
+ * "problemMessage": "Unused import: 'dart:io'.",
21
+ * "correctionMessage": "Try removing the import directive.",
22
+ * "documentation": "https://dart.dev/diagnostics/unused_import"
23
+ * }
24
+ * ]
25
+ * }
26
+ *
27
+ * Severity mapping:
28
+ * - "ERROR" → SARIF "error" (30 min effort)
29
+ * - "WARNING" → SARIF "warning" (15 min effort)
30
+ * - "INFO" → SARIF "note" (5 min effort)
31
+ *
32
+ * @module adapters/dart-analyzer
33
+ */
34
+
35
+ import {
36
+ type AdapterResult,
37
+ wrapResultsInSarif,
38
+ estimateEffortMinutes,
39
+ } from "./common.js";
40
+ import type { SarifLevel } from "../sarif/sarif-builder.js";
41
+
42
+ // ── Types ──────────────────────────────────────────────────────────
43
+
44
+ interface DartDiagnosticLocation {
45
+ file: string;
46
+ range: {
47
+ start: { offset: number; line: number; column: number };
48
+ end: { offset: number; line: number; column: number };
49
+ };
50
+ }
51
+
52
+ interface DartDiagnostic {
53
+ code: string;
54
+ severity: string;
55
+ type: string;
56
+ location: DartDiagnosticLocation;
57
+ problemMessage: string;
58
+ correctionMessage?: string;
59
+ documentation?: string;
60
+ }
61
+
62
+ interface DartAnalyzeOutput {
63
+ version: number;
64
+ diagnostics: DartDiagnostic[];
65
+ }
66
+
67
+ // ── Severity mapping ───────────────────────────────────────────────
68
+
69
+ function mapSeverity(dartSeverity: string): SarifLevel {
70
+ switch (dartSeverity.toUpperCase()) {
71
+ case "ERROR":
72
+ return "error";
73
+ case "WARNING":
74
+ return "warning";
75
+ case "INFO":
76
+ return "note";
77
+ default:
78
+ return "warning";
79
+ }
80
+ }
81
+
82
+ // ── Effort estimates per severity ──────────────────────────────────
83
+
84
+ const EFFORT_BY_SEVERITY: Record<SarifLevel, number> = {
85
+ error: 30,
86
+ warning: 15,
87
+ note: 5,
88
+ none: 0,
89
+ };
90
+
91
+ // ── Public API ─────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Convert `dart analyze --format=json` output to SARIF 2.1.0.
95
+ *
96
+ * @param rawOutput The JSON string or pre-parsed object from `dart analyze`.
97
+ */
98
+ export function adaptDartAnalyzer(rawOutput: unknown): AdapterResult {
99
+ let parsed: DartAnalyzeOutput;
100
+
101
+ if (typeof rawOutput === "string") {
102
+ try {
103
+ parsed = JSON.parse(rawOutput) as DartAnalyzeOutput;
104
+ } catch {
105
+ throw new Error("[dart-analyzer adapter] rawOutput is not valid JSON");
106
+ }
107
+ } else if (rawOutput && typeof rawOutput === "object" && "diagnostics" in rawOutput) {
108
+ parsed = rawOutput as DartAnalyzeOutput;
109
+ } else {
110
+ throw new Error(
111
+ "[dart-analyzer adapter] rawOutput must be a JSON string or an object with a 'diagnostics' array",
112
+ );
113
+ }
114
+
115
+ if (!Array.isArray(parsed.diagnostics)) {
116
+ throw new Error("[dart-analyzer adapter] 'diagnostics' must be an array");
117
+ }
118
+
119
+ const results: object[] = [];
120
+ let totalEffortMinutes = 0;
121
+
122
+ for (const diag of parsed.diagnostics) {
123
+ const level = mapSeverity(diag.severity);
124
+ const effort = EFFORT_BY_SEVERITY[level] ?? estimateEffortMinutes(level);
125
+ totalEffortMinutes += effort;
126
+
127
+ results.push({
128
+ ruleId: diag.code,
129
+ level,
130
+ message: {
131
+ text: diag.problemMessage + (diag.correctionMessage ? ` ${diag.correctionMessage}` : ""),
132
+ },
133
+ locations: [
134
+ {
135
+ physicalLocation: {
136
+ artifactLocation: {
137
+ uri: diag.location.file,
138
+ },
139
+ region: {
140
+ startLine: diag.location.range.start.line,
141
+ startColumn: diag.location.range.start.column,
142
+ endLine: diag.location.range.end.line,
143
+ endColumn: diag.location.range.end.column,
144
+ },
145
+ },
146
+ },
147
+ ],
148
+ properties: {
149
+ effortMinutes: effort,
150
+ ...(diag.documentation ? { helpUri: diag.documentation } : {}),
151
+ },
152
+ });
153
+ }
154
+
155
+ return {
156
+ document: wrapResultsInSarif("dart_analyze", "1.0.0", results),
157
+ sourceTool: "dart_analyze",
158
+ findingCount: parsed.diagnostics.length,
159
+ totalEffortMinutes,
160
+ };
161
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Adapter: `dotnet format --report <path>` JSON output → SARIF 2.1.0.
3
+ *
4
+ * The dotnet format tool emits a JSON array with this shape:
5
+ *
6
+ * [
7
+ * {
8
+ * "DocumentId": { "ProjectId": { "Id": "..." }, "Id": "..." },
9
+ * "FileName": "AuthController.cs",
10
+ * "FilePath": "/absolute/path/to/AuthController.cs",
11
+ * "FileChanges": [
12
+ * {
13
+ * "LineNumber": 84,
14
+ * "CharNumber": 16,
15
+ * "DiagnosticId": "WHITESPACE",
16
+ * "FormatDescription": "Fix whitespace formatting. Delete 5 characters."
17
+ * }
18
+ * ]
19
+ * }
20
+ * ]
21
+ *
22
+ * All dotnet format findings are style/formatting issues, so they
23
+ * map uniformly to SARIF "warning" level with a 5-minute effort
24
+ * estimate (formatting fixes are quick, mechanical changes).
25
+ *
26
+ * @module adapters/dotnet-format
27
+ */
28
+
29
+ import {
30
+ type AdapterResult,
31
+ wrapResultsInSarif,
32
+ } from "./common.js";
33
+
34
+ // ── Types ──────────────────────────────────────────────────────────
35
+
36
+ interface DotnetFileChange {
37
+ LineNumber: number;
38
+ CharNumber: number;
39
+ DiagnosticId: string;
40
+ FormatDescription: string;
41
+ }
42
+
43
+ interface DotnetFormatDocument {
44
+ DocumentId: {
45
+ ProjectId: { Id: string };
46
+ Id: string;
47
+ };
48
+ FileName: string;
49
+ FilePath: string;
50
+ FileChanges: DotnetFileChange[];
51
+ }
52
+
53
+ // ── Public API ─────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Convert `dotnet format --report <path>` JSON output to SARIF 2.1.0.
57
+ *
58
+ * @param rawOutput The JSON string or pre-parsed array from `dotnet format`.
59
+ */
60
+ export function adaptDotnetFormat(rawOutput: unknown): AdapterResult {
61
+ let parsed: DotnetFormatDocument[];
62
+
63
+ if (typeof rawOutput === "string") {
64
+ try {
65
+ parsed = JSON.parse(rawOutput) as DotnetFormatDocument[];
66
+ } catch {
67
+ throw new Error("[dotnet-format adapter] rawOutput is not valid JSON");
68
+ }
69
+ } else if (Array.isArray(rawOutput)) {
70
+ parsed = rawOutput as DotnetFormatDocument[];
71
+ } else {
72
+ throw new Error(
73
+ "[dotnet-format adapter] rawOutput must be a JSON string or an array of document entries",
74
+ );
75
+ }
76
+
77
+ if (!Array.isArray(parsed)) {
78
+ throw new Error("[dotnet-format adapter] parsed output must be an array");
79
+ }
80
+
81
+ const EFFORT_MINUTES = 5;
82
+ const results: object[] = [];
83
+ let findingCount = 0;
84
+ let totalEffortMinutes = 0;
85
+
86
+ for (const doc of parsed) {
87
+ if (!Array.isArray(doc.FileChanges)) continue;
88
+
89
+ for (const change of doc.FileChanges) {
90
+ findingCount++;
91
+ totalEffortMinutes += EFFORT_MINUTES;
92
+
93
+ results.push({
94
+ ruleId: change.DiagnosticId,
95
+ level: "warning",
96
+ message: {
97
+ text: change.FormatDescription,
98
+ },
99
+ locations: [
100
+ {
101
+ physicalLocation: {
102
+ artifactLocation: {
103
+ uri: doc.FilePath,
104
+ },
105
+ region: {
106
+ startLine: change.LineNumber,
107
+ startColumn: change.CharNumber,
108
+ },
109
+ },
110
+ },
111
+ ],
112
+ properties: {
113
+ effortMinutes: EFFORT_MINUTES,
114
+ },
115
+ });
116
+ }
117
+ }
118
+
119
+ return {
120
+ document: wrapResultsInSarif("dotnet_format", "1.0.0", results),
121
+ sourceTool: "dotnet_format",
122
+ findingCount,
123
+ totalEffortMinutes,
124
+ };
125
+ }
@@ -30,6 +30,8 @@ export { adaptSemgrep } from "./semgrep.js";
30
30
  export { adaptEslint } from "./eslint.js";
31
31
  export { adaptBandit } from "./bandit.js";
32
32
  export { adaptStryker } from "./stryker.js";
33
+ export { adaptDartAnalyzer } from "./dart-analyzer.js";
34
+ export { adaptDotnetFormat } from "./dotnet-format.js";
33
35
 
34
36
  export {
35
37
  DEFAULT_EFFORT_BY_SEVERITY,
@@ -44,6 +46,8 @@ import { adaptSemgrep } from "./semgrep.js";
44
46
  import { adaptEslint } from "./eslint.js";
45
47
  import { adaptBandit } from "./bandit.js";
46
48
  import { adaptStryker } from "./stryker.js";
49
+ import { adaptDartAnalyzer } from "./dart-analyzer.js";
50
+ import { adaptDotnetFormat } from "./dotnet-format.js";
47
51
  import type { AdapterResult, KnownScanner } from "./common.js";
48
52
 
49
53
  /**
@@ -70,6 +74,10 @@ export function adaptScannerOutput(
70
74
  return adaptBandit(rawOutput);
71
75
  case "stryker":
72
76
  return adaptStryker(rawOutput);
77
+ case "dart_analyze":
78
+ return adaptDartAnalyzer(rawOutput);
79
+ case "dotnet_format":
80
+ return adaptDotnetFormat(rawOutput);
73
81
  default: {
74
82
  const exhaustive: never = scanner;
75
83
  throw new Error(`[adapters] Unknown scanner: ${String(exhaustive)}`);
@@ -81,6 +81,10 @@ export interface CrapConfig {
81
81
  readonly strictness: Strictness;
82
82
  /** Where the strictness value actually came from. Useful for diagnostics. */
83
83
  readonly strictnessSource: "env" | "file" | "default";
84
+ /** User-defined exclusion patterns (directories with trailing `/`, or file globs). */
85
+ readonly exclude: ReadonlyArray<string>;
86
+ /** Relative paths to directories containing sub-projects (e.g. `["apps", "packages"]`). */
87
+ readonly projectDirs: ReadonlyArray<string>;
84
88
  }
85
89
 
86
90
  /**
@@ -107,6 +111,12 @@ export interface LoadCrapConfigOptions {
107
111
  * @throws {@link CrapConfigError} on any invalid input.
108
112
  */
109
113
  export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
114
+ // Always read the file to extract `exclude`, even when strictness
115
+ // comes from the environment variable.
116
+ const fileResult = readFromFile(options.workspaceRoot);
117
+ const exclude = fileResult?.exclude ?? [];
118
+ const projectDirs = fileResult?.projectDirs ?? [];
119
+
110
120
  const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
111
121
  if (typeof envRaw === "string" && envRaw.trim() !== "") {
112
122
  const normalized = envRaw.trim().toLowerCase();
@@ -116,13 +126,14 @@ export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
116
126
  `Expected one of: ${STRICTNESS_VALUES.join(", ")}.`,
117
127
  );
118
128
  }
119
- return { strictness: normalized, strictnessSource: "env" };
129
+ return { strictness: normalized, strictnessSource: "env", exclude, projectDirs };
120
130
  }
121
131
 
122
- const fromFile = readFromFile(options.workspaceRoot);
123
- if (fromFile) return { strictness: fromFile, strictnessSource: "file" };
132
+ if (fileResult?.strictness) {
133
+ return { strictness: fileResult.strictness, strictnessSource: "file", exclude, projectDirs };
134
+ }
124
135
 
125
- return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default" };
136
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude, projectDirs };
126
137
  }
127
138
 
128
139
  /**
@@ -138,7 +149,13 @@ export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
138
149
  * @returns The validated strictness, or `null` when no
139
150
  * file is present.
140
151
  */
141
- function readFromFile(workspaceRoot: string): Strictness | null {
152
+ interface FileResult {
153
+ strictness: Strictness | null;
154
+ exclude: string[];
155
+ projectDirs: string[];
156
+ }
157
+
158
+ function readFromFile(workspaceRoot: string): FileResult | null {
142
159
  const filePath = join(workspaceRoot, ".claude-crap.json");
143
160
  let raw: string;
144
161
  try {
@@ -166,22 +183,65 @@ function readFromFile(workspaceRoot: string): Strictness | null {
166
183
  );
167
184
  }
168
185
  const doc = parsed as Record<string, unknown>;
169
- if (!("strictness" in doc)) return null;
170
186
 
171
- const value = doc["strictness"];
172
- if (typeof value !== "string") {
173
- throw new CrapConfigError(
174
- `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`,
175
- );
187
+ // Parse strictness
188
+ let strictness: Strictness | null = null;
189
+ if ("strictness" in doc) {
190
+ const value = doc["strictness"];
191
+ if (typeof value !== "string") {
192
+ throw new CrapConfigError(
193
+ `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`,
194
+ );
195
+ }
196
+ const normalized = value.trim().toLowerCase();
197
+ if (!isStrictness(normalized)) {
198
+ throw new CrapConfigError(
199
+ `[crap-config] ${filePath}: 'strictness' is "${value}"; ` +
200
+ `expected one of ${STRICTNESS_VALUES.join(", ")}.`,
201
+ );
202
+ }
203
+ strictness = normalized;
176
204
  }
177
- const normalized = value.trim().toLowerCase();
178
- if (!isStrictness(normalized)) {
179
- throw new CrapConfigError(
180
- `[crap-config] ${filePath}: 'strictness' is "${value}"; ` +
181
- `expected one of ${STRICTNESS_VALUES.join(", ")}.`,
182
- );
205
+
206
+ // Parse exclude
207
+ let exclude: string[] = [];
208
+ if ("exclude" in doc) {
209
+ const raw = doc["exclude"];
210
+ if (!Array.isArray(raw)) {
211
+ throw new CrapConfigError(
212
+ `[crap-config] ${filePath}: 'exclude' must be an array of strings`,
213
+ );
214
+ }
215
+ for (const item of raw) {
216
+ if (typeof item !== "string") {
217
+ throw new CrapConfigError(
218
+ `[crap-config] ${filePath}: every entry in 'exclude' must be a string, got ${typeof item}`,
219
+ );
220
+ }
221
+ }
222
+ exclude = raw as string[];
183
223
  }
184
- return normalized;
224
+
225
+ // Parse projectDirs
226
+ let projectDirs: string[] = [];
227
+ if ("projectDirs" in doc) {
228
+ const raw = doc["projectDirs"];
229
+ if (!Array.isArray(raw)) {
230
+ throw new CrapConfigError(
231
+ `[crap-config] ${filePath}: 'projectDirs' must be an array of strings`,
232
+ );
233
+ }
234
+ for (const item of raw) {
235
+ if (typeof item !== "string") {
236
+ throw new CrapConfigError(
237
+ `[crap-config] ${filePath}: every entry in 'projectDirs' must be a string, got ${typeof item}`,
238
+ );
239
+ }
240
+ }
241
+ projectDirs = raw as string[];
242
+ }
243
+
244
+ return { strictness, exclude, projectDirs };
185
245
  }
186
246
 
187
247
  /**
@@ -15,8 +15,6 @@
15
15
  */
16
16
 
17
17
  import { promises as fs } from "node:fs";
18
- import { join } from "node:path";
19
-
20
18
  import { resolveWithinWorkspace } from "../workspace-guard.js";
21
19
  import { detectLanguageFromPath, type SupportedLanguage } from "../ast/language-config.js";
22
20
  import type { TreeSitterEngine, FunctionMetrics } from "../ast/tree-sitter-engine.js";
@@ -33,6 +33,7 @@ import fastifyStatic from "@fastify/static";
33
33
  import type { Logger } from "pino";
34
34
 
35
35
  import type { CrapConfig } from "../config.js";
36
+ import { createExclusionFilter } from "../shared/exclusions.js";
36
37
  import {
37
38
  computeProjectScore,
38
39
  type ProjectScore,
@@ -64,6 +65,8 @@ export interface StartDashboardOptions {
64
65
  readonly logger: Logger;
65
66
  /** Tree-sitter engine for the /api/complexity endpoint. */
66
67
  readonly astEngine?: TreeSitterEngine;
68
+ /** User-defined exclusion patterns from .claude-crap.json. */
69
+ readonly exclude?: ReadonlyArray<string>;
67
70
  }
68
71
 
69
72
  /**
@@ -104,7 +107,7 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
104
107
  // ------------------------------------------------------------------
105
108
  // /api/health — liveness probe
106
109
  // ------------------------------------------------------------------
107
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.7" }));
110
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.4.0" }));
108
111
 
109
112
  // ------------------------------------------------------------------
110
113
  // /api/score — live project score
@@ -127,7 +130,7 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
127
130
  if (!options.astEngine) {
128
131
  return { threshold: config.cyclomaticMax, totalFunctions: 0, violationCount: 0, topFunctions: [] };
129
132
  }
130
- return buildComplexityReport(config, options.astEngine, logger);
133
+ return buildComplexityReport(config, options.astEngine, logger, options.exclude);
131
134
  });
132
135
 
133
136
  // ------------------------------------------------------------------
@@ -394,12 +397,7 @@ interface ComplexityReport {
394
397
  topFunctions: ComplexityEntry[];
395
398
  }
396
399
 
397
- /** Directories to skip (mirrors workspace-walker.ts). */
398
- const SKIP_DIRS: ReadonlySet<string> = new Set([
399
- "node_modules", ".git", "dist", "build", "out", "target",
400
- ".venv", "venv", "__pycache__", ".cache", ".next", ".nuxt",
401
- ".claude-crap", ".codesight",
402
- ]);
400
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
403
401
 
404
402
  /**
405
403
  * Walk the workspace and collect per-function complexity metrics,
@@ -410,8 +408,10 @@ async function buildComplexityReport(
410
408
  config: CrapConfig,
411
409
  engine: TreeSitterEngine,
412
410
  logger: Logger,
411
+ exclude?: ReadonlyArray<string>,
413
412
  ): Promise<ComplexityReport> {
414
413
  const threshold = config.cyclomaticMax;
414
+ const filter = createExclusionFilter(exclude);
415
415
  const allFunctions: ComplexityEntry[] = [];
416
416
  let totalFunctions = 0;
417
417
 
@@ -423,10 +423,9 @@ async function buildComplexityReport(
423
423
  return;
424
424
  }
425
425
  for (const entry of entries) {
426
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
427
426
  const full = join(dir, entry.name);
428
427
  if (entry.isDirectory()) {
429
- if (SKIP_DIRS.has(entry.name)) continue;
428
+ if (filter.shouldSkipDir(entry.name)) continue;
430
429
  await walk(full);
431
430
  continue;
432
431
  }