claude-crap 0.4.5 → 0.4.7

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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +22 -25
  3. package/dist/dashboard/file-detail.d.ts +6 -0
  4. package/dist/dashboard/file-detail.d.ts.map +1 -1
  5. package/dist/dashboard/file-detail.js +1 -0
  6. package/dist/dashboard/file-detail.js.map +1 -1
  7. package/dist/monorepo/project-map.d.ts.map +1 -1
  8. package/dist/monorepo/project-map.js +135 -6
  9. package/dist/monorepo/project-map.js.map +1 -1
  10. package/dist/scanner/bootstrap.d.ts.map +1 -1
  11. package/dist/scanner/bootstrap.js +2 -2
  12. package/dist/scanner/bootstrap.js.map +1 -1
  13. package/dist/shared/exclusions.d.ts.map +1 -1
  14. package/dist/shared/exclusions.js +22 -0
  15. package/dist/shared/exclusions.js.map +1 -1
  16. package/package.json +1 -1
  17. package/plugin/.claude-plugin/plugin.json +1 -1
  18. package/plugin/bundle/dashboard/public/index.html +216 -7
  19. package/plugin/bundle/mcp-server.mjs +145 -31
  20. package/plugin/bundle/mcp-server.mjs.map +3 -3
  21. package/plugin/hooks/lib/gatekeeper-rules.mjs +274 -45
  22. package/plugin/hooks/lib/quality-gate.mjs +3 -0
  23. package/plugin/package-lock.json +8 -8
  24. package/plugin/package.json +1 -1
  25. package/src/dashboard/file-detail.ts +7 -0
  26. package/src/dashboard/public/index.html +216 -7
  27. package/src/monorepo/project-map.ts +144 -6
  28. package/src/scanner/bootstrap.ts +7 -2
  29. package/src/shared/exclusions.ts +26 -0
  30. package/src/tests/exclusions.test.ts +53 -0
  31. package/src/tests/file-detail-api.test.ts +38 -0
  32. package/src/tests/gatekeeper-rules.test.ts +173 -0
  33. package/src/tests/project-map.test.ts +216 -0
  34. package/src/tests/workspace-walker.test.ts +94 -0
@@ -317,6 +317,95 @@
317
317
  opacity: 0.7;
318
318
  margin-left: 12px;
319
319
  }
320
+ /* CC heat bar — ReportGenerator-style visual severity */
321
+ .heat-bar {
322
+ position: relative;
323
+ width: 140px;
324
+ height: 8px;
325
+ background: rgba(255, 255, 255, 0.06);
326
+ border-radius: 4px;
327
+ overflow: hidden;
328
+ }
329
+ .heat-bar-fill {
330
+ height: 100%;
331
+ border-radius: 4px;
332
+ transition: width 120ms ease-out;
333
+ }
334
+ .heat-bar-threshold {
335
+ position: absolute;
336
+ top: -2px;
337
+ bottom: -2px;
338
+ width: 1px;
339
+ background: rgba(255, 255, 255, 0.35);
340
+ }
341
+ /* CC chip — numeric value + % of threshold */
342
+ .cc-chip {
343
+ display: inline-flex;
344
+ align-items: baseline;
345
+ gap: 6px;
346
+ font-variant-numeric: tabular-nums;
347
+ }
348
+ .cc-chip .cc-value {
349
+ font-weight: 700;
350
+ font-size: 13px;
351
+ }
352
+ .cc-chip .cc-ratio {
353
+ font-size: 11px;
354
+ color: var(--muted);
355
+ }
356
+ /* Method name button — scrolls source view to fn start line */
357
+ .method-jump {
358
+ background: none;
359
+ border: none;
360
+ padding: 0;
361
+ font: inherit;
362
+ font-family: "SF Mono", "Fira Code", "Consolas", monospace;
363
+ font-size: 13px;
364
+ color: var(--accent);
365
+ cursor: pointer;
366
+ }
367
+ .method-jump:hover { text-decoration: underline; }
368
+ /* "Open in editor" icon link */
369
+ .editor-link {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ width: 24px;
374
+ height: 24px;
375
+ border-radius: 4px;
376
+ color: var(--muted);
377
+ text-decoration: none;
378
+ font-size: 13px;
379
+ transition: background 120ms, color 120ms;
380
+ }
381
+ .editor-link:hover {
382
+ background: rgba(62, 166, 255, 0.12);
383
+ color: var(--accent);
384
+ text-decoration: none;
385
+ }
386
+ /* "Show all / show fewer" toggle under the methods table */
387
+ .show-all-btn {
388
+ display: inline-block;
389
+ margin: 12px 0 0 0;
390
+ padding: 6px 12px;
391
+ background: transparent;
392
+ border: 1px solid var(--border);
393
+ border-radius: 6px;
394
+ color: var(--accent);
395
+ font-size: 12px;
396
+ cursor: pointer;
397
+ }
398
+ .show-all-btn:hover {
399
+ background: rgba(62, 166, 255, 0.08);
400
+ }
401
+ /* Line-flash animation when the user jumps to a source line */
402
+ .source-line.jump-target {
403
+ animation: jumpFlash 1.2s ease-out;
404
+ }
405
+ @keyframes jumpFlash {
406
+ 0% { background: rgba(62, 166, 255, 0.35); }
407
+ 100% { background: transparent; }
408
+ }
320
409
  </style>
321
410
  </head>
322
411
  <body>
@@ -372,33 +461,74 @@
372
461
  </div>
373
462
  </div>
374
463
 
375
- <!-- Methods table -->
376
- <div v-if="fileDetail.functions.length" class="section-title">Methods</div>
464
+ <!-- Methods table — top-5 by CC with heat bar + jump + editor link -->
465
+ <div v-if="fileDetail.functions.length" class="section-title">
466
+ Methods
467
+ <span style="color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; margin-left: 8px;">
468
+ threshold CC {{ fileDetail.cyclomaticMax }}
469
+ </span>
470
+ </div>
377
471
  <div v-if="fileDetail.functions.length" class="card">
378
472
  <table>
379
473
  <thead>
380
474
  <tr>
381
475
  <th>Method</th>
382
476
  <th style="text-align: right">Line</th>
383
- <th style="text-align: right">CC</th>
477
+ <th style="width: 180px;">CC</th>
384
478
  <th style="text-align: right">Lines</th>
385
479
  <th>Status</th>
480
+ <th style="width: 32px;"></th>
386
481
  </tr>
387
482
  </thead>
388
483
  <tbody>
389
- <tr v-for="fn in sortedFunctions" :key="fn.startLine">
390
- <td><code>{{ fn.name }}</code></td>
484
+ <tr v-for="fn in visibleFunctions" :key="fn.startLine">
485
+ <td>
486
+ <button
487
+ class="method-jump"
488
+ @click="jumpToLine(fn.startLine)"
489
+ :title="'Jump to line ' + fn.startLine"
490
+ >{{ fn.name }}</button>
491
+ </td>
391
492
  <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.startLine }}</td>
392
- <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.cyclomaticComplexity }}</td>
493
+ <td>
494
+ <div class="cc-chip">
495
+ <div class="heat-bar" :title="ccTooltip(fn)">
496
+ <div
497
+ class="heat-bar-fill"
498
+ :style="heatBarStyle(fn)"
499
+ ></div>
500
+ <div
501
+ class="heat-bar-threshold"
502
+ :style="{ left: thresholdMarker() + '%' }"
503
+ ></div>
504
+ </div>
505
+ <span class="cc-value" :style="{ color: ccColor(fn) }">{{ fn.cyclomaticComplexity }}</span>
506
+ <span class="cc-ratio">{{ ccRatio(fn) }}%</span>
507
+ </div>
508
+ </td>
393
509
  <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.lineCount }}</td>
394
510
  <td>
395
511
  <span v-if="fn.cyclomaticComplexity >= fileDetail.cyclomaticMax * 2" class="pill pill-error">error</span>
396
512
  <span v-else-if="fn.cyclomaticComplexity > fileDetail.cyclomaticMax" class="pill pill-warning">warning</span>
397
513
  <span v-else class="pill pill-note">ok</span>
398
514
  </td>
515
+ <td>
516
+ <a
517
+ class="editor-link"
518
+ :href="editorLink(fn)"
519
+ :title="'Open ' + fileDetail.filePath + ':' + fn.startLine + ' in VS Code'"
520
+ >&#x2197;</a>
521
+ </td>
399
522
  </tr>
400
523
  </tbody>
401
524
  </table>
525
+ <button
526
+ v-if="fileDetail.functions.length > topMethodsLimit"
527
+ class="show-all-btn"
528
+ @click="toggleShowAllMethods()"
529
+ >
530
+ {{ showAllMethods ? 'Show top ' + topMethodsLimit : 'Show all ' + fileDetail.functions.length + ' methods' }}
531
+ </button>
402
532
  </div>
403
533
 
404
534
  <!-- Findings table -->
@@ -622,11 +752,87 @@
622
752
  });
623
753
 
624
754
  // ── File detail computed ──
755
+ const topMethodsLimit = 5;
756
+ const showAllMethods = ref(false);
757
+
625
758
  const sortedFunctions = computed(() => {
626
759
  if (!fileDetail.value) return [];
627
760
  return [...fileDetail.value.functions].sort((a, b) => b.cyclomaticComplexity - a.cyclomaticComplexity);
628
761
  });
629
762
 
763
+ const visibleFunctions = computed(() => {
764
+ if (showAllMethods.value) return sortedFunctions.value;
765
+ return sortedFunctions.value.slice(0, topMethodsLimit);
766
+ });
767
+
768
+ function toggleShowAllMethods() {
769
+ showAllMethods.value = !showAllMethods.value;
770
+ }
771
+
772
+ // ── CC heat-bar helpers ──
773
+ // Fill width is clamped at 3× threshold so a CC of 80 with
774
+ // threshold 15 still produces a visually meaningful bar rather
775
+ // than overflowing. The threshold marker sits at the "1.0×"
776
+ // position (i.e. threshold/3threshold = 33%).
777
+ function ccRatio(fn) {
778
+ const max = fileDetail.value?.cyclomaticMax || 1;
779
+ return Math.round((fn.cyclomaticComplexity / max) * 100);
780
+ }
781
+
782
+ function heatBarStyle(fn) {
783
+ const max = fileDetail.value?.cyclomaticMax || 1;
784
+ const cap = max * 3;
785
+ const pct = Math.min(100, (fn.cyclomaticComplexity / cap) * 100);
786
+ return { width: pct + "%", background: ccColor(fn) };
787
+ }
788
+
789
+ function thresholdMarker() {
790
+ // threshold sits at 1/3 of the bar (since we cap at 3× threshold)
791
+ return 33.33;
792
+ }
793
+
794
+ function ccColor(fn) {
795
+ const max = fileDetail.value?.cyclomaticMax || 1;
796
+ const r = fn.cyclomaticComplexity / max;
797
+ if (r >= 2) return "var(--rating-E)"; // red — error (≥ 2×)
798
+ if (r > 1) return "var(--rating-C)"; // yellow — warning
799
+ if (r > 0.66) return "var(--rating-B)"; // yellow-green — near threshold
800
+ return "var(--rating-A)"; // green — healthy
801
+ }
802
+
803
+ function ccTooltip(fn) {
804
+ const max = fileDetail.value?.cyclomaticMax || 1;
805
+ return (
806
+ "CC " + fn.cyclomaticComplexity +
807
+ " / threshold " + max +
808
+ " (" + ccRatio(fn) + "% of threshold)"
809
+ );
810
+ }
811
+
812
+ // ── Editor deep-link + jump-to-line ──
813
+ // vscode:// handler accepts absolute paths. The `absolutePath`
814
+ // field is resolved server-side through the workspace-traversal
815
+ // guard, so this is safe to paste into an href.
816
+ function editorLink(fn) {
817
+ const abs = fileDetail.value?.absolutePath;
818
+ if (!abs) return "#";
819
+ return "vscode://file" + abs + ":" + fn.startLine + ":1";
820
+ }
821
+
822
+ function jumpToLine(lineNum) {
823
+ // Source lines are keyed by 0-based index, so line N lives at
824
+ // child index N-1 of `.source-view`.
825
+ const view = document.querySelector(".source-view");
826
+ if (!view) return;
827
+ const row = view.children[lineNum - 1];
828
+ if (!row) return;
829
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
830
+ row.classList.remove("jump-target");
831
+ // force reflow so the animation restarts on repeat clicks
832
+ void row.offsetWidth;
833
+ row.classList.add("jump-target");
834
+ }
835
+
630
836
  const sortedFindings = computed(() => {
631
837
  if (!fileDetail.value) return [];
632
838
  return [...fileDetail.value.findings].sort((a, b) => a.startLine - b.startLine);
@@ -777,7 +983,10 @@
777
983
  currentView, selectedFile, fileDetail,
778
984
  score, complexity, loading, error,
779
985
  toolEntries, fileEntries, formatTimestamp,
780
- sortedFunctions, sortedFindings,
986
+ sortedFunctions, sortedFindings, visibleFunctions,
987
+ showAllMethods, toggleShowAllMethods, topMethodsLimit,
988
+ ccRatio, ccColor, ccTooltip, heatBarStyle, thresholdMarker,
989
+ editorLink, jumpToLine,
781
990
  navigateToFile, goBack,
782
991
  lineFindings, lineClass, gutterClass, lineFnLabel,
783
992
  };
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { existsSync, readFileSync, readdirSync } from "node:fs";
32
32
  import { promises as fs } from "node:fs";
33
- import { join, basename, resolve } from "node:path";
33
+ import { join, basename, resolve, relative, isAbsolute } from "node:path";
34
34
  import { execFile } from "node:child_process";
35
35
 
36
36
  // ── Types ──────────────────────────────────────────────────────────────────
@@ -170,11 +170,17 @@ function detectProjectType(dir: string): ProjectType {
170
170
  }
171
171
 
172
172
  // C# — check the well-known single-file marker first, then scan for
173
- // per-project extension files (.csproj / .sln) at this level only.
173
+ // per-project extension files (.csproj / .sln / .slnx) at this level
174
+ // only. .slnx is the XML solution format introduced with .NET 9.
174
175
  if (has("Directory.Build.props")) return "csharp";
175
176
  try {
176
177
  const entries = readdirSync(dir);
177
- if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
178
+ if (
179
+ entries.some(
180
+ (e) =>
181
+ e.endsWith(".csproj") || e.endsWith(".sln") || e.endsWith(".slnx"),
182
+ )
183
+ ) {
178
184
  return "csharp";
179
185
  }
180
186
  } catch {
@@ -210,6 +216,88 @@ function extractWorkspacePatterns(workspaces: unknown): string[] {
210
216
  return [];
211
217
  }
212
218
 
219
+ /**
220
+ * Parse a minimal pnpm-workspace.yaml into a list of package patterns.
221
+ *
222
+ * Supports the shape the pnpm CLI actually emits and documents:
223
+ *
224
+ * packages:
225
+ * - "apps/*"
226
+ * - 'clients/mobile'
227
+ * - tooling/cli
228
+ *
229
+ * Deliberately a bespoke parser: a full YAML engine is a large
230
+ * dependency for a single configuration file, and pnpm's own format is
231
+ * a narrow subset. Unsupported constructs (anchors, flow sequences,
232
+ * nested mappings) fall through as "no patterns", which in turn lets
233
+ * the caller fall back to the conventional directory scan.
234
+ *
235
+ * @param yaml Raw contents of a pnpm-workspace.yaml file.
236
+ * @returns Array of package patterns, or an empty array.
237
+ */
238
+ /**
239
+ * Strip a `#` inline comment from a YAML line while respecting single-
240
+ * and double-quoted scalars. Anchors, block scalars, and escape sequences
241
+ * beyond `\"` are out of scope — pnpm-workspace.yaml never uses them.
242
+ */
243
+ function stripYamlComment(line: string): string {
244
+ let inSingle = false;
245
+ let inDouble = false;
246
+ for (let i = 0; i < line.length; i++) {
247
+ const ch = line[i];
248
+ if (ch === "\\" && inDouble && i + 1 < line.length) {
249
+ i++; // skip the escaped character inside a double-quoted scalar
250
+ continue;
251
+ }
252
+ if (!inDouble && ch === "'") {
253
+ inSingle = !inSingle;
254
+ } else if (!inSingle && ch === '"') {
255
+ inDouble = !inDouble;
256
+ } else if (!inSingle && !inDouble && ch === "#") {
257
+ return line.slice(0, i);
258
+ }
259
+ }
260
+ return line;
261
+ }
262
+
263
+ function parsePnpmWorkspaceYaml(yaml: string): string[] {
264
+ const patterns: string[] = [];
265
+ const lines = yaml.split(/\r?\n/);
266
+ let inPackages = false;
267
+
268
+ for (const rawLine of lines) {
269
+ // Strip inline comments — but only when `#` is outside quotes, so a
270
+ // valid entry like `"packages/#tools"` survives.
271
+ const line = stripYamlComment(rawLine).replace(/\s+$/, "");
272
+ if (line.length === 0) continue;
273
+
274
+ // Top-level key "packages:" starts the list.
275
+ if (/^packages\s*:\s*$/.test(line)) {
276
+ inPackages = true;
277
+ continue;
278
+ }
279
+
280
+ // Any other top-level key ends the packages block.
281
+ if (inPackages && /^[^\s-]/.test(line)) {
282
+ inPackages = false;
283
+ continue;
284
+ }
285
+
286
+ if (!inPackages) continue;
287
+
288
+ // List item: " - value" with optional single/double quotes. The
289
+ // bare-word branch can now safely include `#`, because inline
290
+ // comments are already stripped by stripYamlComment() above.
291
+ const m = /^\s*-\s*("([^"]*)"|'([^']*)'|(\S+))\s*$/.exec(line);
292
+ if (m) {
293
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
294
+ if (value.length > 0) patterns.push(value);
295
+ }
296
+ }
297
+
298
+ return patterns;
299
+ }
300
+
213
301
  /**
214
302
  * Expand a single workspace glob pattern into matching absolute paths.
215
303
  *
@@ -225,14 +313,24 @@ function expandWorkspacePattern(
225
313
  workspaceRoot: string,
226
314
  pattern: string,
227
315
  ): string[] {
316
+ // Guard against patterns that escape the workspace root (e.g.
317
+ // `../shared/*` in pnpm-workspace.yaml). External paths are dropped
318
+ // silently so a misconfigured manifest cannot widen the scan scope.
319
+ const isInsideWorkspace = (candidate: string): boolean => {
320
+ const rel = relative(workspaceRoot, candidate);
321
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
322
+ };
323
+
228
324
  if (pattern.endsWith("/*")) {
229
325
  // Glob: list one level of the parent directory.
230
326
  const parentDir = join(workspaceRoot, pattern.slice(0, -2));
327
+ if (!isInsideWorkspace(parentDir)) return [];
231
328
  try {
232
329
  const entries = readdirSync(parentDir, { withFileTypes: true });
233
330
  return entries
234
331
  .filter((e) => e.isDirectory() && !e.name.startsWith("."))
235
- .map((e) => join(parentDir, e.name));
332
+ .map((e) => join(parentDir, e.name))
333
+ .filter(isInsideWorkspace);
236
334
  } catch {
237
335
  return [];
238
336
  }
@@ -240,6 +338,7 @@ function expandWorkspacePattern(
240
338
 
241
339
  // Plain path — verify it exists and is a directory.
242
340
  const full = resolve(workspaceRoot, pattern);
341
+ if (!isInsideWorkspace(full)) return [];
243
342
  try {
244
343
  const entries = readdirSync(full, { withFileTypes: true });
245
344
  // readdirSync succeeds only for directories; if we got here it exists.
@@ -288,6 +387,24 @@ function collectSubdirectories(
288
387
  }
289
388
  }
290
389
 
390
+ // 1b. pnpm workspaces — package.json does not carry a `workspaces`
391
+ // field under pnpm; the source of truth is pnpm-workspace.yaml.
392
+ const pnpmPath = join(workspaceRoot, "pnpm-workspace.yaml");
393
+ if (existsSync(pnpmPath)) {
394
+ try {
395
+ const raw = readFileSync(pnpmPath, "utf-8");
396
+ const patterns = parsePnpmWorkspaceYaml(raw);
397
+ for (const pattern of patterns) {
398
+ for (const absPath of expandWorkspacePattern(workspaceRoot, pattern)) {
399
+ subdirs.add(absPath);
400
+ }
401
+ }
402
+ } catch {
403
+ // Read error — skip pnpm workspaces source. The parser itself
404
+ // never throws; malformed content just yields an empty array.
405
+ }
406
+ }
407
+
291
408
  // 2. User-configured projectDirs from .claude-crap.json (highest priority).
292
409
  // These can be parent directories scanned one level deep (e.g. "apps")
293
410
  // or direct project paths (e.g. "tools/cli").
@@ -297,8 +414,7 @@ function collectSubdirectories(
297
414
  if (!existsSync(absDir)) continue;
298
415
 
299
416
  // If the directory itself has a project marker, treat it as a project.
300
- const hasMarker = PROJECT_MARKERS.some((m) => existsSync(join(absDir, m)));
301
- if (hasMarker) {
417
+ if (directoryIsProjectRoot(absDir)) {
302
418
  subdirs.add(absDir);
303
419
  continue;
304
420
  }
@@ -344,6 +460,28 @@ const PROJECT_MARKERS = [
344
460
  "pom.xml", "build.gradle", "build.gradle.kts", "Directory.Build.props",
345
461
  ];
346
462
 
463
+ /** Per-project file extensions that indicate a .NET project root. */
464
+ const DOTNET_PROJECT_EXTENSIONS = [".csproj", ".sln", ".slnx"] as const;
465
+
466
+ /**
467
+ * Return true when `absDir` looks like a project root — either because
468
+ * it carries one of the well-known {@link PROJECT_MARKERS} single-file
469
+ * markers, or because it contains a .NET per-project file
470
+ * (`.csproj` / `.sln` / `.slnx`). The .NET branch is separate because
471
+ * those markers use extensions rather than fixed filenames.
472
+ */
473
+ function directoryIsProjectRoot(absDir: string): boolean {
474
+ if (PROJECT_MARKERS.some((m) => existsSync(join(absDir, m)))) return true;
475
+ try {
476
+ const entries = readdirSync(absDir);
477
+ return entries.some((e) =>
478
+ DOTNET_PROJECT_EXTENSIONS.some((ext) => e.endsWith(ext)),
479
+ );
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
347
485
  // ── Public API ─────────────────────────────────────────────────────────────
348
486
 
349
487
  /**
@@ -107,11 +107,16 @@ export function detectProjectType(workspaceRoot: string): ProjectType {
107
107
  return "java";
108
108
  }
109
109
 
110
- // C# detection
110
+ // C# detection — .slnx is the XML solution format introduced with .NET 9.
111
111
  if (has("Directory.Build.props")) return "csharp";
112
112
  try {
113
113
  const entries = readdirSync(workspaceRoot);
114
- if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
114
+ if (
115
+ entries.some(
116
+ (e) =>
117
+ e.endsWith(".csproj") || e.endsWith(".sln") || e.endsWith(".slnx"),
118
+ )
119
+ ) {
115
120
  return "csharp";
116
121
  }
117
122
  } catch {
@@ -37,6 +37,32 @@ export const DEFAULT_SKIP_DIRS: ReadonlySet<string> = new Set([
37
37
  "out",
38
38
  "target",
39
39
  "coverage",
40
+ "artifacts", // CI artefact staging, Maven
41
+ "publish", // `dotnet publish` output
42
+
43
+ // Test coverage report bundles (generated HTML/JS from coverage tools;
44
+ // walking them floods the complexity scanner with synthetic minified
45
+ // functions like `coverage-report/main.js::gG` at CC 80+).
46
+ "coverage-report", // ReportGenerator default (.NET)
47
+ "CoverageReport", // ReportGenerator PascalCase variant
48
+ "coveragereport", // ReportGenerator lowercase fallback
49
+ "TestResults", // `dotnet test` default output
50
+ "cobertura", // Cobertura XML reporter
51
+ "lcov-report", // Istanbul HTML reporter
52
+ "htmlcov", // coverage.py HTML output
53
+
54
+ // Desktop / mobile packaging outputs
55
+ "dist-electron",// Electron-builder
56
+ "release", // Electron-builder, Tauri
57
+
58
+ // .NET per-project build outputs (conventional at any depth)
59
+ "bin",
60
+ "obj",
61
+
62
+ // iOS / macOS dependency + build caches
63
+ "Pods", // CocoaPods
64
+ "DerivedData", // Xcode
65
+ "Carthage", // Swift
40
66
 
41
67
  // Framework build outputs
42
68
  ".next", // Next.js
@@ -31,6 +31,44 @@ describe("DEFAULT_SKIP_DIRS", () => {
31
31
  assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
32
32
  }
33
33
  });
34
+
35
+ it("includes Electron/CI/iOS/.NET build outputs", () => {
36
+ // These directories host compiled or packaged artefacts; counting
37
+ // them inflates the LOC denominator of the TDR on every real
38
+ // Electron, Xcode, or .NET workspace.
39
+ for (const dir of [
40
+ "dist-electron", // Electron-builder
41
+ "release", // Electron-builder, Tauri
42
+ "artifacts", // CI, Maven
43
+ "publish", // dotnet publish
44
+ "bin", // .NET build
45
+ "obj", // .NET build
46
+ "Pods", // CocoaPods
47
+ "DerivedData", // Xcode
48
+ "Carthage", // Swift
49
+ ]) {
50
+ assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing skip dir: ${dir}`);
51
+ }
52
+ });
53
+
54
+ it("includes coverage report directories emitted by test tooling", () => {
55
+ // ReportGenerator (.NET), Istanbul (JS) and dotCover emit generated
56
+ // HTML/JS bundles into these folders. Previously only the bare
57
+ // `coverage` name was skipped, which leaked files like
58
+ // `GanttLite.Server/coverage-report/main.js` into complexity scans
59
+ // and flooded the dashboard with false-positive high-CC findings.
60
+ for (const dir of [
61
+ "coverage-report", // ReportGenerator default
62
+ "CoverageReport", // ReportGenerator PascalCase
63
+ "coveragereport", // ReportGenerator lowercase fallback
64
+ "TestResults", // dotnet test default
65
+ "cobertura", // Cobertura XML output
66
+ "lcov-report", // Istanbul HTML reporter
67
+ "htmlcov", // coverage.py HTML output
68
+ ]) {
69
+ assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing coverage skip dir: ${dir}`);
70
+ }
71
+ });
34
72
  });
35
73
 
36
74
  describe("DEFAULT_SKIP_PATTERNS", () => {
@@ -74,6 +112,21 @@ describe("createExclusionFilter", () => {
74
112
  assert.equal(filter.shouldSkipDir("generated"), true);
75
113
  assert.equal(filter.shouldSkipDir("src"), false);
76
114
  });
115
+
116
+ it("skips coverage report directories from every major test runner", () => {
117
+ // Regression: the ReportGenerator bundle
118
+ // (`coverage-report/main.js`, `class.js`, etc.) used to surface
119
+ // in the dashboard's complexity hotspots as minified code with
120
+ // CC ≥ 80. These directories must never be walked.
121
+ const filter = createExclusionFilter();
122
+ assert.equal(filter.shouldSkipDir("coverage-report"), true);
123
+ assert.equal(filter.shouldSkipDir("CoverageReport"), true);
124
+ assert.equal(filter.shouldSkipDir("coveragereport"), true);
125
+ assert.equal(filter.shouldSkipDir("TestResults"), true);
126
+ assert.equal(filter.shouldSkipDir("cobertura"), true);
127
+ assert.equal(filter.shouldSkipDir("lcov-report"), true);
128
+ assert.equal(filter.shouldSkipDir("htmlcov"), true);
129
+ });
77
130
  });
78
131
 
79
132
  describe("shouldSkipFile", () => {
@@ -190,6 +190,44 @@ describe("buildFileDetail", () => {
190
190
  }
191
191
  });
192
192
 
193
+ it("returns the resolved absolute path so the UI can build editor deep-links", async () => {
194
+ // The file-detail view exposes "Open in editor" buttons that emit
195
+ // `vscode://file/{absolutePath}:{line}` (and JetBrains equivalents).
196
+ // Constructing that URL client-side requires the absolute path —
197
+ // the workspace-relative `filePath` alone is not enough. This
198
+ // characterization test pins that contract so the UI can rely on it.
199
+ const dir = makeTmpDir();
200
+ try {
201
+ writeFileSync(join(dir, "hello.ts"), SAMPLE_TS);
202
+ const store = new SarifStore({
203
+ workspaceRoot: dir,
204
+ outputDir: join(dir, ".claude-crap/reports"),
205
+ });
206
+ const result = await buildFileDetail({
207
+ relativePath: "hello.ts",
208
+ workspaceRoot: dir,
209
+ astEngine: engine,
210
+ sarifStore: store,
211
+ cyclomaticMax: 15,
212
+ });
213
+ assert.equal(
214
+ result.absolutePath,
215
+ join(dir, "hello.ts"),
216
+ `expected absolutePath to join workspaceRoot with filePath, got ${result.absolutePath}`,
217
+ );
218
+ assert.ok(
219
+ result.absolutePath.endsWith("hello.ts"),
220
+ "absolutePath should end with the relative path",
221
+ );
222
+ assert.ok(
223
+ result.absolutePath.startsWith(dir),
224
+ "absolutePath must live under the workspace root (path-traversal defense)",
225
+ );
226
+ } finally {
227
+ rmSync(dir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
193
231
  it("returns empty functions for unsupported languages", async () => {
194
232
  const dir = makeTmpDir();
195
233
  try {