claude-crap 0.3.6 → 0.3.8

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 (88) hide show
  1. package/README.md +25 -0
  2. package/dist/adapters/common.d.ts +1 -1
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +1 -1
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/adapters/dart-analyzer.d.ts +41 -0
  7. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  8. package/dist/adapters/dart-analyzer.js +120 -0
  9. package/dist/adapters/dart-analyzer.js.map +1 -0
  10. package/dist/adapters/index.d.ts +1 -0
  11. package/dist/adapters/index.d.ts.map +1 -1
  12. package/dist/adapters/index.js +4 -0
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/crap-config.d.ts +2 -0
  15. package/dist/crap-config.d.ts.map +1 -1
  16. package/dist/crap-config.js +36 -28
  17. package/dist/crap-config.js.map +1 -1
  18. package/dist/dashboard/file-detail.d.ts +77 -0
  19. package/dist/dashboard/file-detail.d.ts.map +1 -0
  20. package/dist/dashboard/file-detail.js +120 -0
  21. package/dist/dashboard/file-detail.js.map +1 -0
  22. package/dist/dashboard/server.d.ts +5 -0
  23. package/dist/dashboard/server.d.ts.map +1 -1
  24. package/dist/dashboard/server.js +103 -1
  25. package/dist/dashboard/server.js.map +1 -1
  26. package/dist/index.js +36 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/metrics/workspace-walker.d.ts +4 -1
  29. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  30. package/dist/metrics/workspace-walker.js +12 -28
  31. package/dist/metrics/workspace-walker.js.map +1 -1
  32. package/dist/scanner/auto-scan.d.ts +9 -1
  33. package/dist/scanner/auto-scan.d.ts.map +1 -1
  34. package/dist/scanner/auto-scan.js +27 -5
  35. package/dist/scanner/auto-scan.js.map +1 -1
  36. package/dist/scanner/bootstrap.d.ts +1 -1
  37. package/dist/scanner/bootstrap.d.ts.map +1 -1
  38. package/dist/scanner/bootstrap.js +9 -0
  39. package/dist/scanner/bootstrap.js.map +1 -1
  40. package/dist/scanner/complexity-scanner.d.ts +56 -0
  41. package/dist/scanner/complexity-scanner.d.ts.map +1 -0
  42. package/dist/scanner/complexity-scanner.js +161 -0
  43. package/dist/scanner/complexity-scanner.js.map +1 -0
  44. package/dist/scanner/detector.d.ts +24 -4
  45. package/dist/scanner/detector.d.ts.map +1 -1
  46. package/dist/scanner/detector.js +105 -10
  47. package/dist/scanner/detector.js.map +1 -1
  48. package/dist/scanner/runner.d.ts +4 -1
  49. package/dist/scanner/runner.d.ts.map +1 -1
  50. package/dist/scanner/runner.js +12 -3
  51. package/dist/scanner/runner.js.map +1 -1
  52. package/dist/schemas/tool-schemas.d.ts +1 -1
  53. package/dist/schemas/tool-schemas.js +1 -1
  54. package/dist/schemas/tool-schemas.js.map +1 -1
  55. package/dist/shared/exclusions.d.ts +53 -0
  56. package/dist/shared/exclusions.d.ts.map +1 -0
  57. package/dist/shared/exclusions.js +126 -0
  58. package/dist/shared/exclusions.js.map +1 -0
  59. package/package.json +3 -1
  60. package/plugin/.claude-plugin/plugin.json +1 -1
  61. package/plugin/bundle/dashboard/public/index.html +432 -12
  62. package/plugin/bundle/mcp-server.mjs +747 -137
  63. package/plugin/bundle/mcp-server.mjs.map +4 -4
  64. package/plugin/package-lock.json +15 -2
  65. package/plugin/package.json +2 -1
  66. package/scripts/bundle-plugin.mjs +2 -1
  67. package/src/adapters/common.ts +1 -1
  68. package/src/adapters/dart-analyzer.ts +161 -0
  69. package/src/adapters/index.ts +4 -0
  70. package/src/crap-config.ts +55 -18
  71. package/src/dashboard/file-detail.ts +195 -0
  72. package/src/dashboard/public/index.html +432 -12
  73. package/src/dashboard/server.ts +140 -1
  74. package/src/index.ts +37 -4
  75. package/src/metrics/workspace-walker.ts +15 -27
  76. package/src/scanner/auto-scan.ts +41 -4
  77. package/src/scanner/bootstrap.ts +11 -0
  78. package/src/scanner/complexity-scanner.ts +222 -0
  79. package/src/scanner/detector.ts +114 -10
  80. package/src/scanner/runner.ts +12 -2
  81. package/src/schemas/tool-schemas.ts +1 -1
  82. package/src/shared/exclusions.ts +156 -0
  83. package/src/tests/adapters/dispatch.test.ts +2 -2
  84. package/src/tests/auto-scan.test.ts +2 -2
  85. package/src/tests/complexity-scanner.test.ts +263 -0
  86. package/src/tests/exclusions.test.ts +117 -0
  87. package/src/tests/file-detail-api.test.ts +258 -0
  88. 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.6",
3
+ "version": "0.3.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-crap-plugin",
9
- "version": "0.3.6",
9
+ "version": "0.3.8",
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.6",
3
+ "version": "0.3.8",
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"] 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
+ }
@@ -30,6 +30,7 @@ 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";
33
34
 
34
35
  export {
35
36
  DEFAULT_EFFORT_BY_SEVERITY,
@@ -44,6 +45,7 @@ import { adaptSemgrep } from "./semgrep.js";
44
45
  import { adaptEslint } from "./eslint.js";
45
46
  import { adaptBandit } from "./bandit.js";
46
47
  import { adaptStryker } from "./stryker.js";
48
+ import { adaptDartAnalyzer } from "./dart-analyzer.js";
47
49
  import type { AdapterResult, KnownScanner } from "./common.js";
48
50
 
49
51
  /**
@@ -70,6 +72,8 @@ export function adaptScannerOutput(
70
72
  return adaptBandit(rawOutput);
71
73
  case "stryker":
72
74
  return adaptStryker(rawOutput);
75
+ case "dart_analyze":
76
+ return adaptDartAnalyzer(rawOutput);
73
77
  default: {
74
78
  const exhaustive: never = scanner;
75
79
  throw new Error(`[adapters] Unknown scanner: ${String(exhaustive)}`);
@@ -81,6 +81,8 @@ 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>;
84
86
  }
85
87
 
86
88
  /**
@@ -107,6 +109,11 @@ export interface LoadCrapConfigOptions {
107
109
  * @throws {@link CrapConfigError} on any invalid input.
108
110
  */
109
111
  export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
112
+ // Always read the file to extract `exclude`, even when strictness
113
+ // comes from the environment variable.
114
+ const fileResult = readFromFile(options.workspaceRoot);
115
+ const exclude = fileResult?.exclude ?? [];
116
+
110
117
  const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
111
118
  if (typeof envRaw === "string" && envRaw.trim() !== "") {
112
119
  const normalized = envRaw.trim().toLowerCase();
@@ -116,13 +123,14 @@ export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
116
123
  `Expected one of: ${STRICTNESS_VALUES.join(", ")}.`,
117
124
  );
118
125
  }
119
- return { strictness: normalized, strictnessSource: "env" };
126
+ return { strictness: normalized, strictnessSource: "env", exclude };
120
127
  }
121
128
 
122
- const fromFile = readFromFile(options.workspaceRoot);
123
- if (fromFile) return { strictness: fromFile, strictnessSource: "file" };
129
+ if (fileResult?.strictness) {
130
+ return { strictness: fileResult.strictness, strictnessSource: "file", exclude };
131
+ }
124
132
 
125
- return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default" };
133
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude };
126
134
  }
127
135
 
128
136
  /**
@@ -138,7 +146,12 @@ export function loadCrapConfig(options: LoadCrapConfigOptions): CrapConfig {
138
146
  * @returns The validated strictness, or `null` when no
139
147
  * file is present.
140
148
  */
141
- function readFromFile(workspaceRoot: string): Strictness | null {
149
+ interface FileResult {
150
+ strictness: Strictness | null;
151
+ exclude: string[];
152
+ }
153
+
154
+ function readFromFile(workspaceRoot: string): FileResult | null {
142
155
  const filePath = join(workspaceRoot, ".claude-crap.json");
143
156
  let raw: string;
144
157
  try {
@@ -166,22 +179,46 @@ function readFromFile(workspaceRoot: string): Strictness | null {
166
179
  );
167
180
  }
168
181
  const doc = parsed as Record<string, unknown>;
169
- if (!("strictness" in doc)) return null;
170
182
 
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
- );
183
+ // Parse strictness
184
+ let strictness: Strictness | null = null;
185
+ if ("strictness" in doc) {
186
+ const value = doc["strictness"];
187
+ if (typeof value !== "string") {
188
+ throw new CrapConfigError(
189
+ `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`,
190
+ );
191
+ }
192
+ const normalized = value.trim().toLowerCase();
193
+ if (!isStrictness(normalized)) {
194
+ throw new CrapConfigError(
195
+ `[crap-config] ${filePath}: 'strictness' is "${value}"; ` +
196
+ `expected one of ${STRICTNESS_VALUES.join(", ")}.`,
197
+ );
198
+ }
199
+ strictness = normalized;
176
200
  }
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
- );
201
+
202
+ // Parse exclude
203
+ let exclude: string[] = [];
204
+ if ("exclude" in doc) {
205
+ const raw = doc["exclude"];
206
+ if (!Array.isArray(raw)) {
207
+ throw new CrapConfigError(
208
+ `[crap-config] ${filePath}: 'exclude' must be an array of strings`,
209
+ );
210
+ }
211
+ for (const item of raw) {
212
+ if (typeof item !== "string") {
213
+ throw new CrapConfigError(
214
+ `[crap-config] ${filePath}: every entry in 'exclude' must be a string, got ${typeof item}`,
215
+ );
216
+ }
217
+ }
218
+ exclude = raw as string[];
183
219
  }
184
- return normalized;
220
+
221
+ return { strictness, exclude };
185
222
  }
186
223
 
187
224
  /**
@@ -0,0 +1,195 @@
1
+ /**
2
+ * File detail builder for the dashboard.
3
+ *
4
+ * Given a workspace-relative file path, this module produces a rich
5
+ * detail payload combining source code, per-function AST metrics, and
6
+ * SARIF findings filtered to that file. The dashboard uses this to
7
+ * render a ReportGenerator-style annotated code view.
8
+ *
9
+ * The builder is extracted into its own module (rather than inlined in
10
+ * `server.ts`) so that:
11
+ * - The logic is unit-testable without booting the HTTP server.
12
+ * - The types are importable by both the Fastify route and tests.
13
+ *
14
+ * @module dashboard/file-detail
15
+ */
16
+
17
+ import { promises as fs } from "node:fs";
18
+ import { resolveWithinWorkspace } from "../workspace-guard.js";
19
+ import { detectLanguageFromPath, type SupportedLanguage } from "../ast/language-config.js";
20
+ import type { TreeSitterEngine, FunctionMetrics } from "../ast/tree-sitter-engine.js";
21
+ import type { SarifStore, IngestedFinding } from "../sarif/sarif-store.js";
22
+
23
+ // ── Types ─────────────────────────────────────────────────────────
24
+
25
+ /** Per-function entry in the detail response. */
26
+ export interface FileDetailFunction {
27
+ readonly name: string;
28
+ readonly startLine: number;
29
+ readonly endLine: number;
30
+ readonly cyclomaticComplexity: number;
31
+ readonly lineCount: number;
32
+ }
33
+
34
+ /** Per-finding entry in the detail response. */
35
+ export interface FileDetailFinding {
36
+ readonly ruleId: string;
37
+ readonly level: string;
38
+ readonly message: string;
39
+ readonly sourceTool: string;
40
+ readonly startLine: number;
41
+ readonly startColumn: number;
42
+ readonly endLine: number;
43
+ readonly endColumn: number;
44
+ readonly effortMinutes: number;
45
+ }
46
+
47
+ /** Summary statistics for the file. */
48
+ export interface FileDetailSummary {
49
+ readonly totalFindings: number;
50
+ readonly errorCount: number;
51
+ readonly warningCount: number;
52
+ readonly noteCount: number;
53
+ readonly totalEffortMinutes: number;
54
+ readonly avgComplexity: number;
55
+ readonly maxComplexity: number;
56
+ }
57
+
58
+ /** Full response payload for the file detail endpoint. */
59
+ export interface FileDetailResponse {
60
+ readonly filePath: string;
61
+ readonly language: SupportedLanguage | null;
62
+ readonly physicalLoc: number;
63
+ readonly logicalLoc: number;
64
+ readonly cyclomaticMax: number;
65
+ readonly sourceLines: string[];
66
+ readonly functions: FileDetailFunction[];
67
+ readonly findings: FileDetailFinding[];
68
+ readonly summary: FileDetailSummary;
69
+ }
70
+
71
+ /** Input accepted by {@link buildFileDetail}. */
72
+ export interface BuildFileDetailInput {
73
+ readonly relativePath: string;
74
+ readonly workspaceRoot: string;
75
+ readonly astEngine?: TreeSitterEngine | undefined;
76
+ readonly sarifStore: SarifStore;
77
+ readonly cyclomaticMax: number;
78
+ }
79
+
80
+ // ── Builder ───────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Build the file detail payload. Pure function aside from the file
84
+ * read and the tree-sitter analysis (both deterministic for a given
85
+ * file).
86
+ *
87
+ * @throws When the file does not exist or the path escapes the workspace.
88
+ */
89
+ export async function buildFileDetail(
90
+ input: BuildFileDetailInput,
91
+ ): Promise<FileDetailResponse> {
92
+ const { relativePath, workspaceRoot, astEngine, sarifStore, cyclomaticMax } = input;
93
+
94
+ // 1. Guard against path traversal
95
+ const absolutePath = resolveWithinWorkspace(workspaceRoot, relativePath);
96
+
97
+ // 2. Read source
98
+ const source = await fs.readFile(absolutePath, "utf8");
99
+ const sourceLines = source.split(/\r?\n/);
100
+ // Remove trailing empty line from files ending with \n
101
+ if (sourceLines.length > 0 && sourceLines[sourceLines.length - 1] === "") {
102
+ sourceLines.pop();
103
+ }
104
+
105
+ const physicalLoc = sourceLines.length;
106
+ let logicalLoc = 0;
107
+ for (const line of sourceLines) {
108
+ if (line.trim().length > 0) logicalLoc += 1;
109
+ }
110
+
111
+ // 3. AST analysis (if language is supported)
112
+ const language = detectLanguageFromPath(relativePath);
113
+ let functions: FileDetailFunction[] = [];
114
+
115
+ if (language && astEngine) {
116
+ try {
117
+ const metrics = await astEngine.analyzeFile({
118
+ filePath: absolutePath,
119
+ language,
120
+ });
121
+ functions = metrics.functions.map((fn: FunctionMetrics) => ({
122
+ name: fn.name,
123
+ startLine: fn.startLine,
124
+ endLine: fn.endLine,
125
+ cyclomaticComplexity: fn.cyclomaticComplexity,
126
+ lineCount: fn.lineCount,
127
+ }));
128
+ } catch {
129
+ // Analysis failure is non-fatal — return empty functions
130
+ }
131
+ }
132
+
133
+ // 4. Filter SARIF findings for this file
134
+ const allFindings = sarifStore.list();
135
+ const fileFindings = allFindings.filter(
136
+ (f: IngestedFinding) => f.location.uri === relativePath,
137
+ );
138
+
139
+ const findings: FileDetailFinding[] = fileFindings.map((f: IngestedFinding) => ({
140
+ ruleId: f.ruleId,
141
+ level: f.level,
142
+ message: f.message,
143
+ sourceTool: f.sourceTool,
144
+ startLine: f.location.startLine,
145
+ startColumn: f.location.startColumn,
146
+ endLine: f.location.endLine ?? f.location.startLine,
147
+ endColumn: f.location.endColumn ?? 0,
148
+ effortMinutes:
149
+ typeof f.properties?.effortMinutes === "number"
150
+ ? f.properties.effortMinutes
151
+ : 0,
152
+ }));
153
+
154
+ // 5. Build summary
155
+ let errorCount = 0;
156
+ let warningCount = 0;
157
+ let noteCount = 0;
158
+ let totalEffortMinutes = 0;
159
+
160
+ for (const f of findings) {
161
+ if (f.level === "error") errorCount += 1;
162
+ else if (f.level === "warning") warningCount += 1;
163
+ else if (f.level === "note") noteCount += 1;
164
+ totalEffortMinutes += f.effortMinutes;
165
+ }
166
+
167
+ const complexities = functions.map((f) => f.cyclomaticComplexity);
168
+ const maxComplexity = complexities.length > 0 ? Math.max(...complexities) : 0;
169
+ const avgComplexity =
170
+ complexities.length > 0
171
+ ? Math.round(
172
+ (complexities.reduce((a, b) => a + b, 0) / complexities.length) * 100,
173
+ ) / 100
174
+ : 0;
175
+
176
+ return {
177
+ filePath: relativePath,
178
+ language,
179
+ physicalLoc,
180
+ logicalLoc,
181
+ cyclomaticMax,
182
+ sourceLines,
183
+ functions,
184
+ findings,
185
+ summary: {
186
+ totalFindings: findings.length,
187
+ errorCount,
188
+ warningCount,
189
+ noteCount,
190
+ totalEffortMinutes,
191
+ avgComplexity,
192
+ maxComplexity,
193
+ },
194
+ };
195
+ }