claude-crap 0.4.6 → 0.4.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.
@@ -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
  };
@@ -72,9 +72,15 @@ export interface StartDashboardOptions {
72
72
  /**
73
73
  * Handle returned by {@link startDashboard}. Use `url` to build the
74
74
  * link the user clicks; call `close()` during shutdown.
75
+ *
76
+ * `adopted === true` means another claude-crap process already owned
77
+ * the dashboard port when we booted, and we are piggy-backing on its
78
+ * HTTP server. Adopted handles have a no-op `close()` because tearing
79
+ * down the Fastify instance would strand the other MCP servers.
75
80
  */
76
81
  export interface DashboardHandle {
77
82
  readonly url: string;
83
+ readonly adopted: boolean;
78
84
  close(): Promise<void>;
79
85
  }
80
86
 
@@ -87,6 +93,28 @@ export interface DashboardHandle {
87
93
  */
88
94
  export async function startDashboard(options: StartDashboardOptions): Promise<DashboardHandle> {
89
95
  const { config, sarifStore, workspaceStatsProvider, logger } = options;
96
+ const pidFilePath = resolvePidFilePath(config);
97
+
98
+ // Adopt-don't-steal: if a prior MCP server is already serving the
99
+ // dashboard on this port AND is healthy, piggy-back on it instead of
100
+ // killing it. This is what keeps N concurrent launchers from
101
+ // thrashing the port in an endless SIGTERM loop.
102
+ const adoption = await tryAdoptExisting(pidFilePath, config.dashboardPort, logger);
103
+ if (adoption) {
104
+ logger.info(
105
+ { url: adoption.url, ownerPid: adoption.pid, port: config.dashboardPort },
106
+ "adopted existing claude-crap dashboard",
107
+ );
108
+ return {
109
+ url: adoption.url,
110
+ adopted: true,
111
+ async close() {
112
+ // No-op: we never bound a socket of our own, so there is
113
+ // nothing to release. Removing the pidfile here would make the
114
+ // owner's `close()` race with our cleanup.
115
+ },
116
+ };
117
+ }
90
118
 
91
119
  // Resolve the public/ directory. After `npm run build` the compiled
92
120
  // server lives in `dist/dashboard/server.js`, but we keep the static
@@ -173,22 +201,41 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
173
201
  return reply.sendFile("index.html");
174
202
  });
175
203
 
176
- // Kill any stale dashboard from a previous session so we always
177
- // bind to the configured port. This mirrors claude-mem's PID file
178
- // pattern: write a PID file when alive, check + kill on next boot.
179
- const pidFilePath = resolvePidFilePath(config);
180
- await killStaleDashboard(pidFilePath, config.dashboardPort, logger);
181
-
182
- await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
204
+ // The pidfile was either missing, stale, or pointed at a zombie —
205
+ // `tryAdoptExisting` has already cleaned it up. Try to bind. If we
206
+ // lose a race against another launcher that bound between our probe
207
+ // and our listen, fall back to adoption instead of failing.
208
+ try {
209
+ await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
210
+ } catch (err) {
211
+ const code = (err as NodeJS.ErrnoException).code;
212
+ if (code === "EADDRINUSE") {
213
+ await fastify.close().catch(() => { /* best effort */ });
214
+ const raceAdoption = await tryAdoptExisting(pidFilePath, config.dashboardPort, logger);
215
+ if (raceAdoption) {
216
+ logger.info(
217
+ { url: raceAdoption.url, ownerPid: raceAdoption.pid, port: config.dashboardPort },
218
+ "dashboard bind lost race, adopted concurrent owner",
219
+ );
220
+ return {
221
+ url: raceAdoption.url,
222
+ adopted: true,
223
+ async close() { /* no-op — see adopted branch above */ },
224
+ };
225
+ }
226
+ }
227
+ throw err;
228
+ }
183
229
 
184
230
  const url = `http://127.0.0.1:${config.dashboardPort}`;
185
231
  logger.info({ url, publicRoot }, "claude-crap dashboard listening");
186
232
 
187
- // Write PID file so the next session can find and kill us.
233
+ // Write PID file so sibling MCP servers can find us and adopt.
188
234
  writePidFile(pidFilePath, config.dashboardPort);
189
235
 
190
236
  return {
191
237
  url,
238
+ adopted: false,
192
239
  async close() {
193
240
  removePidFile(pidFilePath);
194
241
  await fastify.close();
@@ -310,71 +357,101 @@ function isPidAlive(pid: number): boolean {
310
357
  }
311
358
 
312
359
  /**
313
- * Read the PID file, kill any stale dashboard process, and free the
314
- * port so the current session can bind to it. This is the key
315
- * difference from the port-fallback approach: instead of drifting to
316
- * 5118, 5119, etc., we reclaim the configured port every time.
360
+ * Probe an existing dashboard and decide whether the current process
361
+ * can adopt it instead of binding its own Fastify server.
317
362
  *
318
- * @param pidFilePath Absolute path to `dashboard.pid`.
319
- * @param port The configured dashboard port.
320
- * @param logger Pino logger for diagnostics.
363
+ * Returns `{ url, pid }` only when all four conditions hold:
364
+ * 1. A pidfile exists and parses as JSON.
365
+ * 2. The recorded PID is still alive (signal-0 probe).
366
+ * 3. The pidfile's recorded port matches the configured port.
367
+ * 4. A GET on `/api/health` responds within ~500ms.
368
+ *
369
+ * Returns `null` in every other case, but with a side-effect that makes
370
+ * the call-site's next step obvious:
371
+ * - Missing / corrupt / dead-PID / port-mismatch → pidfile is removed
372
+ * so the caller can bind cleanly.
373
+ * - Zombie (PID alive, port unresponsive) → stale owner is
374
+ * SIGKILL'd and the pidfile is removed. This is the one case where
375
+ * we still have to kill something, because the socket belongs to a
376
+ * process that is not talking HTTP anymore.
321
377
  */
322
- async function killStaleDashboard(
378
+ async function tryAdoptExisting(
323
379
  pidFilePath: string,
324
380
  port: number,
325
381
  logger: Logger,
326
- ): Promise<void> {
327
- if (!existsSync(pidFilePath)) return;
382
+ ): Promise<{ url: string; pid: number } | null> {
383
+ if (!existsSync(pidFilePath)) return null;
328
384
 
329
385
  let stale: DashboardPidFile;
330
386
  try {
331
387
  stale = JSON.parse(readFileSync(pidFilePath, "utf8"));
332
388
  } catch {
333
- // Corrupted PID file remove it and move on.
389
+ logger.info({ pidFilePath }, "corrupt dashboard pidfile, removing");
334
390
  removePidFile(pidFilePath);
335
- return;
391
+ return null;
336
392
  }
337
393
 
338
394
  if (!isPidAlive(stale.pid)) {
339
- logger.info({ stalePid: stale.pid }, "stale dashboard PID file found (process dead), removing");
395
+ logger.info({ stalePid: stale.pid }, "stale dashboard pidfile (process dead), removing");
340
396
  removePidFile(pidFilePath);
341
- return;
397
+ return null;
342
398
  }
343
399
 
344
- // Process is alive — kill it so we can reclaim the port.
345
- logger.info(
346
- { stalePid: stale.pid, port: stale.port, startedAt: stale.startedAt },
347
- "killing stale dashboard process from previous session",
348
- );
400
+ if (stale.port !== port) {
401
+ // The recorded owner is on a different port than we want. Don't
402
+ // adopt it, don't kill it — just treat the pidfile as unrelated.
403
+ logger.info(
404
+ { stalePort: stale.port, wantedPort: port },
405
+ "dashboard pidfile points at different port, ignoring",
406
+ );
407
+ removePidFile(pidFilePath);
408
+ return null;
409
+ }
410
+
411
+ const healthy = await probeDashboardHealth(port);
412
+ if (healthy) {
413
+ return { url: `http://127.0.0.1:${port}`, pid: stale.pid };
414
+ }
349
415
 
416
+ // Zombie: PID is alive but not serving HTTP. Most likely the owner
417
+ // crashed mid-init or is stuck. Terminate it so we can take over.
418
+ logger.warn(
419
+ { stalePid: stale.pid, port },
420
+ "dashboard pidfile owner is unresponsive, terminating",
421
+ );
350
422
  try {
351
423
  process.kill(stale.pid, "SIGTERM");
352
424
  } catch {
353
- // Permission denied or already gone — remove PID file either way.
354
- removePidFile(pidFilePath);
355
- return;
425
+ /* already gone */
356
426
  }
357
-
358
- // Wait up to 3 seconds for the process to exit.
359
427
  for (let i = 0; i < 30; i++) {
360
428
  if (!isPidAlive(stale.pid)) break;
361
429
  await new Promise((r) => setTimeout(r, 100));
362
430
  }
363
-
364
- // If still alive after 3s, escalate to SIGKILL.
365
431
  if (isPidAlive(stale.pid)) {
366
- try {
367
- process.kill(stale.pid, "SIGKILL");
368
- } catch {
369
- /* best effort */
370
- }
432
+ try { process.kill(stale.pid, "SIGKILL"); } catch { /* best effort */ }
371
433
  await new Promise((r) => setTimeout(r, 200));
372
434
  }
373
-
374
435
  removePidFile(pidFilePath);
375
-
376
- // Give the OS a moment to release the TCP port after the process dies.
436
+ // Let the OS release the TCP port before the caller tries to bind.
377
437
  await new Promise((r) => setTimeout(r, 300));
438
+ return null;
439
+ }
440
+
441
+ /**
442
+ * Low-latency health probe. Resolves `true` when the dashboard replies
443
+ * 2xx to `/api/health` within 500ms, `false` on any other outcome
444
+ * (timeout, connection refused, 5xx, etc.).
445
+ */
446
+ async function probeDashboardHealth(port: number): Promise<boolean> {
447
+ try {
448
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
449
+ signal: AbortSignal.timeout(500),
450
+ });
451
+ return res.ok;
452
+ } catch {
453
+ return false;
454
+ }
378
455
  }
379
456
 
380
457
  // ── Complexity report builder ──────────────────────────────────────
@@ -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