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.
- package/CHANGELOG.md +51 -0
- package/dist/dashboard/file-detail.d.ts +6 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -1
- package/dist/dashboard/file-detail.js +1 -0
- package/dist/dashboard/file-detail.js.map +1 -1
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +99 -31
- package/dist/dashboard/server.js.map +1 -1
- package/dist/shared/exclusions.d.ts.map +1 -1
- package/dist/shared/exclusions.js +10 -0
- package/dist/shared/exclusions.js.map +1 -1
- package/dist/tests/helpers/dashboard-test-helpers.d.ts +94 -0
- package/dist/tests/helpers/dashboard-test-helpers.d.ts.map +1 -0
- package/dist/tests/helpers/dashboard-test-helpers.js +159 -0
- package/dist/tests/helpers/dashboard-test-helpers.js.map +1 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +216 -7
- package/plugin/bundle/mcp-server.mjs +88 -13
- package/plugin/bundle/mcp-server.mjs.map +2 -2
- package/plugin/hooks/lib/quality-gate.mjs +3 -0
- package/src/dashboard/file-detail.ts +7 -0
- package/src/dashboard/public/index.html +216 -7
- package/src/dashboard/server.ts +119 -42
- package/src/shared/exclusions.ts +11 -0
- package/src/tests/dashboard-adoption.test.ts +553 -0
- package/src/tests/exclusions.test.ts +34 -0
- package/src/tests/file-detail-api.test.ts +38 -0
- package/src/tests/helpers/dashboard-test-helpers.ts +203 -0
- package/src/tests/workspace-walker.test.ts +30 -0
|
@@ -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">
|
|
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="
|
|
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
|
|
390
|
-
<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
|
|
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
|
+
>↗</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
|
};
|
package/src/dashboard/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
-
*
|
|
314
|
-
*
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
|
378
|
+
async function tryAdoptExisting(
|
|
323
379
|
pidFilePath: string,
|
|
324
380
|
port: number,
|
|
325
381
|
logger: Logger,
|
|
326
|
-
): Promise<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────
|
package/src/shared/exclusions.ts
CHANGED
|
@@ -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
|