claude-crap 0.4.6 → 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.
@@ -150,6 +150,9 @@ const LOC_WALK_SKIP_DIRS = new Set([
150
150
  ".git",
151
151
  // Build outputs
152
152
  "dist", "build", "bundle", "out", "target", "coverage",
153
+ // Test coverage report bundles (ReportGenerator, Istanbul, coverage.py, dotnet test)
154
+ "coverage-report", "CoverageReport", "coveragereport",
155
+ "TestResults", "cobertura", "lcov-report", "htmlcov",
153
156
  // Framework build outputs
154
157
  ".next", ".nuxt", ".output", ".vercel", ".svelte-kit",
155
158
  ".astro", ".angular", ".turbo", ".parcel-cache", ".expo",
@@ -58,6 +58,12 @@ export interface FileDetailSummary {
58
58
  /** Full response payload for the file detail endpoint. */
59
59
  export interface FileDetailResponse {
60
60
  readonly filePath: string;
61
+ /**
62
+ * Absolute path on the host filesystem, already resolved through the
63
+ * workspace-traversal guard. The UI uses this to build editor
64
+ * deep-links (e.g. `vscode://file/{absolutePath}:{line}`).
65
+ */
66
+ readonly absolutePath: string;
61
67
  readonly language: SupportedLanguage | null;
62
68
  readonly physicalLoc: number;
63
69
  readonly logicalLoc: number;
@@ -175,6 +181,7 @@ export async function buildFileDetail(
175
181
 
176
182
  return {
177
183
  filePath: relativePath,
184
+ absolutePath,
178
185
  language,
179
186
  physicalLoc,
180
187
  logicalLoc,
@@ -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
  };
@@ -40,6 +40,17 @@ export const DEFAULT_SKIP_DIRS: ReadonlySet<string> = new Set([
40
40
  "artifacts", // CI artefact staging, Maven
41
41
  "publish", // `dotnet publish` output
42
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
+
43
54
  // Desktop / mobile packaging outputs
44
55
  "dist-electron",// Electron-builder
45
56
  "release", // Electron-builder, Tauri
@@ -50,6 +50,25 @@ describe("DEFAULT_SKIP_DIRS", () => {
50
50
  assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing skip dir: ${dir}`);
51
51
  }
52
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
+ });
53
72
  });
54
73
 
55
74
  describe("DEFAULT_SKIP_PATTERNS", () => {
@@ -93,6 +112,21 @@ describe("createExclusionFilter", () => {
93
112
  assert.equal(filter.shouldSkipDir("generated"), true);
94
113
  assert.equal(filter.shouldSkipDir("src"), false);
95
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
+ });
96
130
  });
97
131
 
98
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 {
@@ -61,4 +61,34 @@ describe("estimateWorkspaceLoc — default skip dirs exclude common build artefa
61
61
  rmSync(dir, { recursive: true, force: true });
62
62
  }
63
63
  });
64
+
65
+ it("skips generated test coverage report bundles", async () => {
66
+ // Regression: the dashboard flagged GanttLite.Server/coverage-report/main.js
67
+ // (ReportGenerator output) as the hottest file in the project with
68
+ // four CC-80+ errors, all coming from minified `main.js` / `class.js`.
69
+ // The walker must not descend into any coverage-report variant.
70
+ const dir = makeTmpDir();
71
+ try {
72
+ touch(join(dir, "src/real.ts"), "export const x = 1;\n");
73
+
74
+ touch(join(dir, "coverage-report/main.js"), "function gG(){return 1}\n");
75
+ touch(join(dir, "coverage-report/class.js"), "function N(){return 1}\n");
76
+ touch(join(dir, "CoverageReport/index.js"), "function a(){}\n");
77
+ touch(join(dir, "coveragereport/bundle.js"), "function b(){}\n");
78
+ touch(join(dir, "TestResults/report.js"), "function c(){}\n");
79
+ touch(join(dir, "cobertura/cobertura.js"), "function d(){}\n");
80
+ touch(join(dir, "lcov-report/prettify.js"), "function e(){}\n");
81
+ touch(join(dir, "htmlcov/pycov.js"), "function f(){}\n");
82
+
83
+ const result = await estimateWorkspaceLoc(dir);
84
+
85
+ assert.equal(
86
+ result.fileCount,
87
+ 1,
88
+ `coverage-report bundles leaked into walk — fileCount=${result.fileCount}`,
89
+ );
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
64
94
  });