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
@@ -192,6 +192,131 @@
192
192
  font-size: 12px;
193
193
  overflow-x: auto;
194
194
  }
195
+ .file-link {
196
+ cursor: pointer;
197
+ color: var(--accent);
198
+ }
199
+ .file-link:hover {
200
+ text-decoration: underline;
201
+ }
202
+ .back-link {
203
+ display: inline-flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ color: var(--accent);
207
+ font-size: 13px;
208
+ cursor: pointer;
209
+ margin-bottom: 16px;
210
+ }
211
+ .back-link:hover { text-decoration: underline; }
212
+ .file-header {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 12px;
216
+ margin-bottom: 24px;
217
+ flex-wrap: wrap;
218
+ }
219
+ .file-header h2 {
220
+ margin: 0;
221
+ font-size: 18px;
222
+ font-weight: 600;
223
+ word-break: break-all;
224
+ }
225
+ .lang-badge {
226
+ display: inline-block;
227
+ padding: 2px 10px;
228
+ border-radius: 999px;
229
+ font-size: 11px;
230
+ font-weight: 600;
231
+ text-transform: uppercase;
232
+ letter-spacing: 0.04em;
233
+ background: rgba(62, 166, 255, 0.15);
234
+ color: var(--accent);
235
+ }
236
+ .grid.file-summary {
237
+ grid-template-columns: repeat(4, minmax(0, 1fr));
238
+ margin-bottom: 8px;
239
+ }
240
+ @media (max-width: 880px) {
241
+ .grid.file-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
242
+ }
243
+ .card .stat-value {
244
+ font-size: 28px;
245
+ font-weight: 700;
246
+ margin: 4px 0 8px 0;
247
+ font-variant-numeric: tabular-nums;
248
+ }
249
+ .source-view {
250
+ background: var(--card);
251
+ border: 1px solid var(--border);
252
+ border-radius: 10px;
253
+ overflow-x: auto;
254
+ font-family: "SF Mono", "Fira Code", "Consolas", monospace;
255
+ font-size: 12px;
256
+ line-height: 1.6;
257
+ }
258
+ .source-line {
259
+ display: flex;
260
+ min-height: 20px;
261
+ border-bottom: 1px solid transparent;
262
+ }
263
+ .source-line:hover {
264
+ background: rgba(255, 255, 255, 0.03);
265
+ }
266
+ .line-error {
267
+ background: rgba(231, 76, 60, 0.12);
268
+ }
269
+ .line-warning {
270
+ background: rgba(241, 196, 15, 0.08);
271
+ }
272
+ .line-note {
273
+ background: rgba(62, 166, 255, 0.08);
274
+ }
275
+ .line-fn-start {
276
+ border-top: 1px solid rgba(46, 204, 113, 0.25);
277
+ }
278
+ .line-number {
279
+ flex-shrink: 0;
280
+ width: 56px;
281
+ padding: 0 12px 0 12px;
282
+ text-align: right;
283
+ color: var(--muted);
284
+ user-select: none;
285
+ opacity: 0.6;
286
+ }
287
+ .line-gutter {
288
+ flex-shrink: 0;
289
+ width: 28px;
290
+ text-align: center;
291
+ padding: 0 2px;
292
+ }
293
+ .gutter-marker {
294
+ display: inline-block;
295
+ width: 18px;
296
+ height: 18px;
297
+ border-radius: 50%;
298
+ font-size: 10px;
299
+ font-weight: 700;
300
+ line-height: 18px;
301
+ text-align: center;
302
+ color: #0b0d10;
303
+ }
304
+ .gutter-error { background: var(--rating-E); }
305
+ .gutter-warning { background: var(--rating-C); }
306
+ .gutter-note { background: var(--accent); }
307
+ .line-code {
308
+ flex: 1;
309
+ padding: 0 16px 0 4px;
310
+ white-space: pre;
311
+ tab-size: 4;
312
+ }
313
+ .line-fn-label {
314
+ color: var(--rating-A);
315
+ font-size: 10px;
316
+ font-weight: 600;
317
+ opacity: 0.7;
318
+ margin-left: 12px;
319
+ }
195
320
  </style>
196
321
  </head>
197
322
  <body>
@@ -209,11 +334,129 @@
209
334
  </header>
210
335
 
211
336
  <main>
212
- <div v-if="loading" class="empty">Loading project score…</div>
213
- <div v-else-if="error" class="empty">
337
+ <!-- ═══ FILE DETAIL VIEW ═══ -->
338
+ <template v-if="currentView === 'file-detail' && fileDetail">
339
+ <div class="back-link" @click="goBack()">&#8592; Back to dashboard</div>
340
+ <div class="file-header">
341
+ <h2>{{ fileDetail.filePath }}</h2>
342
+ <span v-if="fileDetail.language" class="lang-badge">{{ fileDetail.language }}</span>
343
+ </div>
344
+
345
+ <!-- Summary cards -->
346
+ <div class="grid file-summary">
347
+ <div class="card">
348
+ <h2>Lines of Code</h2>
349
+ <div class="stat-value">{{ fileDetail.physicalLoc }}</div>
350
+ <div class="detail">{{ fileDetail.logicalLoc }} logical lines</div>
351
+ </div>
352
+ <div class="card">
353
+ <h2>Functions</h2>
354
+ <div class="stat-value">{{ fileDetail.functions.length }}</div>
355
+ <div class="detail">
356
+ Avg CC: {{ fileDetail.summary.avgComplexity }} · Max CC: {{ fileDetail.summary.maxComplexity }}
357
+ </div>
358
+ </div>
359
+ <div class="card">
360
+ <h2>Findings</h2>
361
+ <div class="stat-value" :style="{ color: fileDetail.summary.totalFindings > 0 ? 'var(--rating-D)' : 'var(--rating-A)' }">
362
+ {{ fileDetail.summary.totalFindings }}
363
+ </div>
364
+ <div class="detail">
365
+ {{ fileDetail.summary.errorCount }} error · {{ fileDetail.summary.warningCount }} warning · {{ fileDetail.summary.noteCount }} note
366
+ </div>
367
+ </div>
368
+ <div class="card">
369
+ <h2>Effort</h2>
370
+ <div class="stat-value">{{ fileDetail.summary.totalEffortMinutes }}m</div>
371
+ <div class="detail">Estimated remediation</div>
372
+ </div>
373
+ </div>
374
+
375
+ <!-- Methods table -->
376
+ <div v-if="fileDetail.functions.length" class="section-title">Methods</div>
377
+ <div v-if="fileDetail.functions.length" class="card">
378
+ <table>
379
+ <thead>
380
+ <tr>
381
+ <th>Method</th>
382
+ <th style="text-align: right">Line</th>
383
+ <th style="text-align: right">CC</th>
384
+ <th style="text-align: right">Lines</th>
385
+ <th>Status</th>
386
+ </tr>
387
+ </thead>
388
+ <tbody>
389
+ <tr v-for="fn in sortedFunctions" :key="fn.startLine">
390
+ <td><code>{{ fn.name }}</code></td>
391
+ <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>
393
+ <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.lineCount }}</td>
394
+ <td>
395
+ <span v-if="fn.cyclomaticComplexity >= fileDetail.cyclomaticMax * 2" class="pill pill-error">error</span>
396
+ <span v-else-if="fn.cyclomaticComplexity > fileDetail.cyclomaticMax" class="pill pill-warning">warning</span>
397
+ <span v-else class="pill pill-note">ok</span>
398
+ </td>
399
+ </tr>
400
+ </tbody>
401
+ </table>
402
+ </div>
403
+
404
+ <!-- Findings table -->
405
+ <div v-if="fileDetail.findings.length" class="section-title">Findings</div>
406
+ <div v-if="fileDetail.findings.length" class="card">
407
+ <table>
408
+ <thead>
409
+ <tr>
410
+ <th>Rule</th>
411
+ <th>Level</th>
412
+ <th>Message</th>
413
+ <th style="text-align: right">Line</th>
414
+ <th>Tool</th>
415
+ <th style="text-align: right">Effort</th>
416
+ </tr>
417
+ </thead>
418
+ <tbody>
419
+ <tr v-for="(f, i) in sortedFindings" :key="i">
420
+ <td><code>{{ f.ruleId }}</code></td>
421
+ <td>
422
+ <span :class="'pill pill-' + f.level">{{ f.level }}</span>
423
+ </td>
424
+ <td style="max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ f.message }}</td>
425
+ <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ f.startLine }}</td>
426
+ <td>{{ f.sourceTool }}</td>
427
+ <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ f.effortMinutes }}m</td>
428
+ </tr>
429
+ </tbody>
430
+ </table>
431
+ </div>
432
+
433
+ <!-- Annotated source code -->
434
+ <div class="section-title">Source</div>
435
+ <div class="source-view">
436
+ <div
437
+ v-for="(line, idx) in fileDetail.sourceLines"
438
+ :key="idx"
439
+ :class="['source-line', lineClass(idx + 1)]"
440
+ >
441
+ <div class="line-number">{{ idx + 1 }}</div>
442
+ <div class="line-gutter">
443
+ <span
444
+ v-if="lineFindings(idx + 1).length"
445
+ :class="['gutter-marker', gutterClass(idx + 1)]"
446
+ :title="lineFindings(idx + 1).map(f => f.ruleId + ': ' + f.message).join('\\n')"
447
+ >{{ lineFindings(idx + 1).length }}</span>
448
+ </div>
449
+ <div class="line-code">{{ line }}<span v-if="lineFnLabel(idx + 1)" class="line-fn-label">{{ lineFnLabel(idx + 1) }}</span></div>
450
+ </div>
451
+ </div>
452
+ </template>
453
+
454
+ <!-- ═══ DASHBOARD VIEW ═══ -->
455
+ <div v-else-if="currentView === 'dashboard' && loading" class="empty">Loading project score…</div>
456
+ <div v-else-if="currentView === 'dashboard' && error" class="empty">
214
457
  Failed to load score: <code>{{ error }}</code>
215
458
  </div>
216
- <template v-else-if="score">
459
+ <template v-else-if="currentView === 'dashboard' && score">
217
460
  <!-- Top summary cards -->
218
461
  <div class="grid summary">
219
462
  <div class="card">
@@ -283,7 +526,7 @@
283
526
  </thead>
284
527
  <tbody>
285
528
  <tr v-for="[file, count] in fileEntries" :key="file">
286
- <td><code>{{ file }}</code></td>
529
+ <td><code class="file-link" @click="navigateToFile(file)">{{ file }}</code></td>
287
530
  <td style="text-align: right">{{ count }}</td>
288
531
  </tr>
289
532
  </tbody>
@@ -291,6 +534,46 @@
291
534
  <div v-else class="empty">No findings in the consolidated SARIF report yet.</div>
292
535
  </div>
293
536
 
537
+ <!-- Complexity hotspots -->
538
+ <div class="section-title">Complexity Hotspots</div>
539
+ <div class="card">
540
+ <div v-if="complexity" style="margin-bottom: 12px; color: var(--muted); font-size: 13px;">
541
+ Threshold: <strong style="color: var(--fg);">{{ complexity.threshold }}</strong>
542
+ &nbsp;·&nbsp;
543
+ {{ complexity.totalFunctions }} functions analyzed
544
+ &nbsp;·&nbsp;
545
+ <span :style="{ color: complexity.violationCount > 0 ? 'var(--rating-D)' : 'var(--rating-A)' }">
546
+ {{ complexity.violationCount }} violation{{ complexity.violationCount !== 1 ? 's' : '' }}
547
+ </span>
548
+ </div>
549
+ <table v-if="complexity && complexity.topFunctions.length">
550
+ <thead>
551
+ <tr>
552
+ <th>File</th>
553
+ <th>Function</th>
554
+ <th style="text-align: right">CC</th>
555
+ <th style="text-align: right">Lines</th>
556
+ <th>Status</th>
557
+ </tr>
558
+ </thead>
559
+ <tbody>
560
+ <tr v-for="fn in complexity.topFunctions" :key="fn.filePath + ':' + fn.startLine">
561
+ <td><code class="file-link" @click="navigateToFile(fn.filePath)">{{ fn.filePath }}</code></td>
562
+ <td><code>{{ fn.name }}</code></td>
563
+ <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.cyclomaticComplexity }}</td>
564
+ <td style="text-align: right; font-variant-numeric: tabular-nums;">{{ fn.lineCount }}</td>
565
+ <td>
566
+ <span v-if="fn.cyclomaticComplexity >= complexity.threshold * 2" class="pill pill-error">error</span>
567
+ <span v-else-if="fn.cyclomaticComplexity > complexity.threshold" class="pill pill-warning">warning</span>
568
+ <span v-else class="pill pill-note">ok</span>
569
+ </td>
570
+ </tr>
571
+ </tbody>
572
+ </table>
573
+ <div v-else-if="complexity" class="empty">No functions analyzed yet.</div>
574
+ <div v-else class="empty">Loading complexity data…</div>
575
+ </div>
576
+
294
577
  <!-- Where to drill in -->
295
578
  <div class="section-title">Reports</div>
296
579
  <div class="card">
@@ -311,11 +594,18 @@
311
594
  </div>
312
595
 
313
596
  <script>
314
- const { createApp, ref, computed, onMounted } = Vue;
597
+ const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
315
598
 
316
599
  createApp({
317
600
  setup() {
601
+ // ── Navigation state ──
602
+ const currentView = ref("dashboard"); // "dashboard" | "file-detail"
603
+ const selectedFile = ref(null);
604
+ const fileDetail = ref(null);
605
+
606
+ // ── Dashboard state ──
318
607
  const score = ref(null);
608
+ const complexity = ref(null);
319
609
  const loading = ref(true);
320
610
  const error = ref(null);
321
611
 
@@ -331,14 +621,131 @@
331
621
  .slice(0, 10);
332
622
  });
333
623
 
334
- function formatTimestamp(iso) {
624
+ // ── File detail computed ──
625
+ const sortedFunctions = computed(() => {
626
+ if (!fileDetail.value) return [];
627
+ return [...fileDetail.value.functions].sort((a, b) => b.cyclomaticComplexity - a.cyclomaticComplexity);
628
+ });
629
+
630
+ const sortedFindings = computed(() => {
631
+ if (!fileDetail.value) return [];
632
+ return [...fileDetail.value.findings].sort((a, b) => a.startLine - b.startLine);
633
+ });
634
+
635
+ // Build a per-line lookup: line number → array of findings on that line
636
+ const findingsByLine = computed(() => {
637
+ const map = {};
638
+ if (!fileDetail.value) return map;
639
+ for (const f of fileDetail.value.findings) {
640
+ const start = f.startLine;
641
+ const end = f.endLine || start;
642
+ for (let ln = start; ln <= end; ln++) {
643
+ if (!map[ln]) map[ln] = [];
644
+ map[ln].push(f);
645
+ }
646
+ }
647
+ return map;
648
+ });
649
+
650
+ // Build a per-line lookup: line number → worst finding level
651
+ const worstLevelByLine = computed(() => {
652
+ const map = {};
653
+ const rank = { error: 3, warning: 2, note: 1 };
654
+ for (const [ln, findings] of Object.entries(findingsByLine.value)) {
655
+ let worst = 0;
656
+ let worstLevel = null;
657
+ for (const f of findings) {
658
+ const r = rank[f.level] || 0;
659
+ if (r > worst) { worst = r; worstLevel = f.level; }
660
+ }
661
+ map[ln] = worstLevel;
662
+ }
663
+ return map;
664
+ });
665
+
666
+ // Build a set of function start lines for labels
667
+ const fnStartLines = computed(() => {
668
+ const map = {};
669
+ if (!fileDetail.value) return map;
670
+ for (const fn of fileDetail.value.functions) {
671
+ map[fn.startLine] = fn.name + " (CC:" + fn.cyclomaticComplexity + ")";
672
+ }
673
+ return map;
674
+ });
675
+
676
+ function lineFindings(lineNum) {
677
+ return findingsByLine.value[lineNum] || [];
678
+ }
679
+
680
+ function lineClass(lineNum) {
681
+ const level = worstLevelByLine.value[lineNum];
682
+ if (level === "error") return "line-error";
683
+ if (level === "warning") return "line-warning";
684
+ if (level === "note") return "line-note";
685
+ if (fnStartLines.value[lineNum]) return "line-fn-start";
686
+ return "";
687
+ }
688
+
689
+ function gutterClass(lineNum) {
690
+ const level = worstLevelByLine.value[lineNum];
691
+ if (level === "error") return "gutter-error";
692
+ if (level === "warning") return "gutter-warning";
693
+ return "gutter-note";
694
+ }
695
+
696
+ function lineFnLabel(lineNum) {
697
+ return fnStartLines.value[lineNum] || "";
698
+ }
699
+
700
+ // ── Navigation ──
701
+ function navigateToFile(path) {
702
+ window.location.hash = "#file=" + encodeURIComponent(path);
703
+ }
704
+
705
+ function goBack() {
706
+ window.location.hash = "";
707
+ }
708
+
709
+ async function loadFileDetail(path) {
710
+ selectedFile.value = path;
711
+ currentView.value = "file-detail";
712
+ fileDetail.value = null;
335
713
  try {
336
- return new Date(iso).toLocaleString();
337
- } catch {
338
- return iso;
714
+ const res = await fetch("/api/file-detail?path=" + encodeURIComponent(path));
715
+ if (!res.ok) throw new Error("HTTP " + res.status);
716
+ fileDetail.value = await res.json();
717
+ } catch (err) {
718
+ fileDetail.value = null;
719
+ error.value = "Failed to load file detail: " + (err.message || err);
720
+ currentView.value = "dashboard";
339
721
  }
340
722
  }
341
723
 
724
+ function handleHashChange() {
725
+ const hash = window.location.hash;
726
+ if (hash.startsWith("#file=")) {
727
+ const path = decodeURIComponent(hash.substring(6));
728
+ loadFileDetail(path);
729
+ } else {
730
+ currentView.value = "dashboard";
731
+ selectedFile.value = null;
732
+ fileDetail.value = null;
733
+ }
734
+ }
735
+
736
+ // ── Dashboard data ──
737
+ function formatTimestamp(iso) {
738
+ try { return new Date(iso).toLocaleString(); }
739
+ catch { return iso; }
740
+ }
741
+
742
+ async function refreshComplexity() {
743
+ try {
744
+ const res = await fetch("/api/complexity");
745
+ if (res.ok) complexity.value = await res.json();
746
+ } catch { /* non-fatal */ }
747
+ }
748
+
342
749
  async function refresh() {
343
750
  loading.value = true;
344
751
  error.value = null;
@@ -351,16 +758,29 @@
351
758
  } finally {
352
759
  loading.value = false;
353
760
  }
761
+ refreshComplexity();
354
762
  }
355
763
 
356
764
  onMounted(() => {
357
765
  refresh();
358
- // Poll every 10s so the dashboard stays roughly in sync
359
- // with new ingestions without requiring a manual reload.
360
766
  setInterval(refresh, 10_000);
767
+ window.addEventListener("hashchange", handleHashChange);
768
+ // Check if page loaded with a hash already
769
+ handleHashChange();
770
+ });
771
+
772
+ onUnmounted(() => {
773
+ window.removeEventListener("hashchange", handleHashChange);
361
774
  });
362
775
 
363
- return { score, loading, error, toolEntries, fileEntries, formatTimestamp };
776
+ return {
777
+ currentView, selectedFile, fileDetail,
778
+ score, complexity, loading, error,
779
+ toolEntries, fileEntries, formatTimestamp,
780
+ sortedFunctions, sortedFindings,
781
+ navigateToFile, goBack,
782
+ lineFindings, lineClass, gutterClass, lineFnLabel,
783
+ };
364
784
  },
365
785
  }).mount("#app");
366
786
  </script>
@@ -33,12 +33,16 @@ 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,
39
40
  type WorkspaceStats,
40
41
  } from "../metrics/score.js";
41
42
  import type { SarifStore } from "../sarif/sarif-store.js";
43
+ import type { TreeSitterEngine } from "../ast/tree-sitter-engine.js";
44
+ import { detectLanguageFromPath } from "../ast/language-config.js";
45
+ import { buildFileDetail } from "./file-detail.js";
42
46
 
43
47
  /**
44
48
  * Callback used by the dashboard to refresh workspace LOC stats on
@@ -59,6 +63,10 @@ export interface StartDashboardOptions {
59
63
  readonly workspaceStatsProvider: WorkspaceStatsProvider;
60
64
  /** Pino logger from the MCP server (writes to stderr). */
61
65
  readonly logger: Logger;
66
+ /** Tree-sitter engine for the /api/complexity endpoint. */
67
+ readonly astEngine?: TreeSitterEngine;
68
+ /** User-defined exclusion patterns from .claude-crap.json. */
69
+ readonly exclude?: ReadonlyArray<string>;
62
70
  }
63
71
 
64
72
  /**
@@ -99,7 +107,7 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
99
107
  // ------------------------------------------------------------------
100
108
  // /api/health — liveness probe
101
109
  // ------------------------------------------------------------------
102
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.6" }));
110
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.8" }));
103
111
 
104
112
  // ------------------------------------------------------------------
105
113
  // /api/score — live project score
@@ -115,6 +123,46 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
115
123
  // ------------------------------------------------------------------
116
124
  fastify.get("/api/sarif", async () => sarifStore.toSarifDocument());
117
125
 
126
+ // ------------------------------------------------------------------
127
+ // /api/complexity — top complex functions across the workspace
128
+ // ------------------------------------------------------------------
129
+ fastify.get("/api/complexity", async () => {
130
+ if (!options.astEngine) {
131
+ return { threshold: config.cyclomaticMax, totalFunctions: 0, violationCount: 0, topFunctions: [] };
132
+ }
133
+ return buildComplexityReport(config, options.astEngine, logger, options.exclude);
134
+ });
135
+
136
+ // ------------------------------------------------------------------
137
+ // /api/file-detail — per-file source, metrics, and findings
138
+ // ------------------------------------------------------------------
139
+ fastify.get("/api/file-detail", async (request, reply) => {
140
+ const { path: filePath } = request.query as { path?: string };
141
+ if (!filePath) {
142
+ return reply.status(400).send({ error: "Missing required query parameter: path" });
143
+ }
144
+ try {
145
+ const detail = await buildFileDetail({
146
+ relativePath: filePath,
147
+ workspaceRoot: config.pluginRoot,
148
+ astEngine: options.astEngine,
149
+ sarifStore,
150
+ cyclomaticMax: config.cyclomaticMax,
151
+ });
152
+ return detail;
153
+ } catch (err) {
154
+ const msg = (err as Error).message;
155
+ if (msg.includes("ENOENT") || msg.includes("not found")) {
156
+ return reply.status(404).send({ error: `File not found: ${filePath}` });
157
+ }
158
+ if (msg.includes("escapes the workspace")) {
159
+ return reply.status(400).send({ error: msg });
160
+ }
161
+ logger.error({ err: msg, filePath }, "file-detail endpoint error");
162
+ return reply.status(500).send({ error: "Internal server error" });
163
+ }
164
+ });
165
+
118
166
  // ------------------------------------------------------------------
119
167
  // / — explicit SPA fallback for index.html
120
168
  // ------------------------------------------------------------------
@@ -329,6 +377,97 @@ async function killStaleDashboard(
329
377
  await new Promise((r) => setTimeout(r, 300));
330
378
  }
331
379
 
380
+ // ── Complexity report builder ──────────────────────────────────────
381
+
382
+ /** Entry in the complexity report's top-functions list. */
383
+ interface ComplexityEntry {
384
+ filePath: string;
385
+ name: string;
386
+ cyclomaticComplexity: number;
387
+ startLine: number;
388
+ endLine: number;
389
+ lineCount: number;
390
+ }
391
+
392
+ /** Shape returned by GET /api/complexity. */
393
+ interface ComplexityReport {
394
+ threshold: number;
395
+ totalFunctions: number;
396
+ violationCount: number;
397
+ topFunctions: ComplexityEntry[];
398
+ }
399
+
400
+ // Directory exclusions are now centralized in src/shared/exclusions.ts.
401
+
402
+ /**
403
+ * Walk the workspace and collect per-function complexity metrics,
404
+ * returning the top 20 most complex functions. This runs on demand
405
+ * when the dashboard requests /api/complexity.
406
+ */
407
+ async function buildComplexityReport(
408
+ config: CrapConfig,
409
+ engine: TreeSitterEngine,
410
+ logger: Logger,
411
+ exclude?: ReadonlyArray<string>,
412
+ ): Promise<ComplexityReport> {
413
+ const threshold = config.cyclomaticMax;
414
+ const filter = createExclusionFilter(exclude);
415
+ const allFunctions: ComplexityEntry[] = [];
416
+ let totalFunctions = 0;
417
+
418
+ async function walk(dir: string): Promise<void> {
419
+ let entries;
420
+ try {
421
+ entries = await fs.readdir(dir, { withFileTypes: true });
422
+ } catch {
423
+ return;
424
+ }
425
+ for (const entry of entries) {
426
+ const full = join(dir, entry.name);
427
+ if (entry.isDirectory()) {
428
+ if (filter.shouldSkipDir(entry.name)) continue;
429
+ await walk(full);
430
+ continue;
431
+ }
432
+ if (!entry.isFile()) continue;
433
+ const language = detectLanguageFromPath(entry.name);
434
+ if (!language) continue;
435
+ try {
436
+ const metrics = await engine.analyzeFile({ filePath: full, language });
437
+ for (const fn of metrics.functions) {
438
+ totalFunctions += 1;
439
+ allFunctions.push({
440
+ filePath: full.startsWith(config.pluginRoot)
441
+ ? full.substring(config.pluginRoot.length + 1)
442
+ : full,
443
+ name: fn.name,
444
+ cyclomaticComplexity: fn.cyclomaticComplexity,
445
+ startLine: fn.startLine,
446
+ endLine: fn.endLine,
447
+ lineCount: fn.lineCount,
448
+ });
449
+ }
450
+ } catch (err) {
451
+ logger.warn(
452
+ { filePath: full, err: (err as Error).message },
453
+ "complexity-report: failed to analyze file",
454
+ );
455
+ }
456
+ }
457
+ }
458
+
459
+ await walk(config.pluginRoot);
460
+
461
+ // Sort by complexity descending and take top 20
462
+ allFunctions.sort((a, b) => b.cyclomaticComplexity - a.cyclomaticComplexity);
463
+ const topFunctions = allFunctions.slice(0, 20);
464
+ const violationCount = allFunctions.filter(
465
+ (f) => f.cyclomaticComplexity > threshold,
466
+ ).length;
467
+
468
+ return { threshold, totalFunctions, violationCount, topFunctions };
469
+ }
470
+
332
471
  /**
333
472
  * Wrap {@link computeProjectScore} so the dashboard endpoint can call
334
473
  * it with the live store and provide consistent location metadata.