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.
- package/README.md +25 -0
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +1 -1
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/dart-analyzer.d.ts +41 -0
- package/dist/adapters/dart-analyzer.d.ts.map +1 -0
- package/dist/adapters/dart-analyzer.js +120 -0
- package/dist/adapters/dart-analyzer.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/crap-config.d.ts +2 -0
- package/dist/crap-config.d.ts.map +1 -1
- package/dist/crap-config.js +36 -28
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/file-detail.d.ts +77 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -0
- package/dist/dashboard/file-detail.js +120 -0
- package/dist/dashboard/file-detail.js.map +1 -0
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +103 -1
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +36 -4
- package/dist/index.js.map +1 -1
- package/dist/metrics/workspace-walker.d.ts +4 -1
- package/dist/metrics/workspace-walker.d.ts.map +1 -1
- package/dist/metrics/workspace-walker.js +12 -28
- package/dist/metrics/workspace-walker.js.map +1 -1
- package/dist/scanner/auto-scan.d.ts +9 -1
- package/dist/scanner/auto-scan.d.ts.map +1 -1
- package/dist/scanner/auto-scan.js +27 -5
- package/dist/scanner/auto-scan.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +9 -0
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/complexity-scanner.d.ts +56 -0
- package/dist/scanner/complexity-scanner.d.ts.map +1 -0
- package/dist/scanner/complexity-scanner.js +161 -0
- package/dist/scanner/complexity-scanner.js.map +1 -0
- package/dist/scanner/detector.d.ts +24 -4
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +105 -10
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts +4 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +12 -3
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +1 -1
- package/dist/schemas/tool-schemas.js +1 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/dist/shared/exclusions.d.ts +53 -0
- package/dist/shared/exclusions.d.ts.map +1 -0
- package/dist/shared/exclusions.js +126 -0
- package/dist/shared/exclusions.js.map +1 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +432 -12
- package/plugin/bundle/mcp-server.mjs +747 -137
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +15 -2
- package/plugin/package.json +2 -1
- package/scripts/bundle-plugin.mjs +2 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dart-analyzer.ts +161 -0
- package/src/adapters/index.ts +4 -0
- package/src/crap-config.ts +55 -18
- package/src/dashboard/file-detail.ts +195 -0
- package/src/dashboard/public/index.html +432 -12
- package/src/dashboard/server.ts +140 -1
- package/src/index.ts +37 -4
- package/src/metrics/workspace-walker.ts +15 -27
- package/src/scanner/auto-scan.ts +41 -4
- package/src/scanner/bootstrap.ts +11 -0
- package/src/scanner/complexity-scanner.ts +222 -0
- package/src/scanner/detector.ts +114 -10
- package/src/scanner/runner.ts +12 -2
- package/src/schemas/tool-schemas.ts +1 -1
- package/src/shared/exclusions.ts +156 -0
- package/src/tests/adapters/dispatch.test.ts +2 -2
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/complexity-scanner.test.ts +263 -0
- package/src/tests/exclusions.test.ts +117 -0
- package/src/tests/file-detail-api.test.ts +258 -0
- 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
|
-
|
|
213
|
-
<
|
|
337
|
+
<!-- ═══ FILE DETAIL VIEW ═══ -->
|
|
338
|
+
<template v-if="currentView === 'file-detail' && fileDetail">
|
|
339
|
+
<div class="back-link" @click="goBack()">← 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
|
+
·
|
|
543
|
+
{{ complexity.totalFunctions }} functions analyzed
|
|
544
|
+
·
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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 {
|
|
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>
|
package/src/dashboard/server.ts
CHANGED
|
@@ -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.
|
|
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.
|