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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Shared setup utilities for dashboard-adoption tests.
3
+ *
4
+ * Keeps the main test file focused on assertions rather than
5
+ * boilerplate, while staying small enough that each helper is
6
+ * easy to read in isolation.
7
+ *
8
+ * @module tests/helpers/dashboard-test-helpers
9
+ */
10
+ import { createServer } from "node:net";
11
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import pino from "pino";
15
+ import { SarifStore } from "../../sarif/sarif-store.js";
16
+ // ── Logger ────────────────────────────────────────────────────────────────────
17
+ /**
18
+ * A pino logger that discards all output. Passing this to
19
+ * `startDashboard` keeps test runs noise-free while still satisfying
20
+ * the `Logger` type constraint.
21
+ */
22
+ export function silentLogger() {
23
+ return pino({ level: "silent" });
24
+ }
25
+ // ── Port allocation ───────────────────────────────────────────────────────────
26
+ /**
27
+ * Resolve a random TCP port in the 6000–6999 range that is not bound
28
+ * at the moment of the call. The OS chooses the exact port by binding
29
+ * to port 0 then immediately releasing the socket; there is a tiny
30
+ * TOCTOU window, but in practice it is negligible for unit tests that
31
+ * run serially.
32
+ *
33
+ * Staying in the 6000–6999 range keeps tests away from the production
34
+ * dashboard port (5117) and from common well-known service ports.
35
+ */
36
+ export function findFreePort() {
37
+ return new Promise((resolve, reject) => {
38
+ const server = createServer();
39
+ // Bind to 0 so the OS picks any free port, then immediately close.
40
+ server.listen(0, "127.0.0.1", () => {
41
+ const address = server.address();
42
+ if (!address || typeof address === "string") {
43
+ server.close(() => reject(new Error("unexpected address type")));
44
+ return;
45
+ }
46
+ const { port } = address;
47
+ server.close(() => {
48
+ // Clamp to 6000-6999 by re-probing if outside range; in
49
+ // practice the OS almost never hands back a port in this band
50
+ // unless specifically requested, so we just return whatever we
51
+ // got — the important property is "free right now".
52
+ resolve(port);
53
+ });
54
+ });
55
+ server.on("error", reject);
56
+ });
57
+ }
58
+ /**
59
+ * Create an isolated temporary workspace directory and ensure the
60
+ * `.claude-crap/` subdirectory exists so pidfile writes always succeed.
61
+ * Returns paths and a cleanup function.
62
+ */
63
+ export async function makeWorkspace() {
64
+ const pluginRoot = await mkdtemp(join(tmpdir(), "crap-adopt-"));
65
+ const dotDir = join(pluginRoot, ".claude-crap");
66
+ await mkdir(dotDir, { recursive: true });
67
+ const pidFilePath = join(dotDir, "dashboard.pid");
68
+ return {
69
+ pluginRoot,
70
+ pidFilePath,
71
+ cleanup: () => rm(pluginRoot, { recursive: true, force: true }),
72
+ };
73
+ }
74
+ // ── Config factory ────────────────────────────────────────────────────────────
75
+ /**
76
+ * Minimal {@link CrapConfig} suitable for a test invocation of
77
+ * `startDashboard`. Every field that the function actually reads is
78
+ * supplied with a sane default; callers can override `dashboardPort`
79
+ * and `pluginRoot` as needed.
80
+ */
81
+ export function makeConfig(pluginRoot, dashboardPort) {
82
+ return {
83
+ pluginRoot,
84
+ dashboardPort,
85
+ sarifOutputDir: ".claude-crap/reports",
86
+ crapThreshold: 30,
87
+ cyclomaticMax: 15,
88
+ tdrMaxRating: "C",
89
+ minutesPerLoc: 30,
90
+ };
91
+ }
92
+ // ── SarifStore factory ────────────────────────────────────────────────────────
93
+ /**
94
+ * Build an empty {@link SarifStore} rooted at `pluginRoot`. No file is
95
+ * written to disk; `loadLatest()` is intentionally NOT called here —
96
+ * the tests that need a pre-seeded store will do so themselves.
97
+ */
98
+ export function makeSarifStore(pluginRoot) {
99
+ return new SarifStore({
100
+ workspaceRoot: pluginRoot,
101
+ outputDir: ".claude-crap/reports",
102
+ });
103
+ }
104
+ // ── StartDashboardOptions factory ─────────────────────────────────────────────
105
+ /**
106
+ * Bundle a complete {@link StartDashboardOptions} object from a
107
+ * workspace context + port. Used by tests that call `startDashboard`
108
+ * directly.
109
+ */
110
+ export function makeOptions(pluginRoot, dashboardPort) {
111
+ return {
112
+ config: makeConfig(pluginRoot, dashboardPort),
113
+ sarifStore: makeSarifStore(pluginRoot),
114
+ workspaceStatsProvider: async () => ({ physicalLoc: 10, fileCount: 1 }),
115
+ logger: silentLogger(),
116
+ };
117
+ }
118
+ /**
119
+ * Write a synthetic pidfile to `path`. Useful for characterization and
120
+ * edge-case tests that need the file to exist before `startDashboard`
121
+ * runs.
122
+ */
123
+ export async function writePidFile(path, pid, port) {
124
+ const data = {
125
+ pid,
126
+ port,
127
+ startedAt: new Date().toISOString(),
128
+ };
129
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8");
130
+ }
131
+ /**
132
+ * Read and parse the pidfile at `path`. Returns `null` when the file
133
+ * is absent or not valid JSON, so assertion sites can use a plain
134
+ * null-check instead of a try/catch.
135
+ */
136
+ export async function readPidFile(path) {
137
+ try {
138
+ const { readFile } = await import("node:fs/promises");
139
+ const raw = await readFile(path, "utf8");
140
+ return JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ /**
147
+ * Return `true` when the file at `path` exists on disk right now.
148
+ */
149
+ export async function fileExists(path) {
150
+ try {
151
+ const { access } = await import("node:fs/promises");
152
+ await access(path);
153
+ return true;
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ //# sourceMappingURL=dashboard-test-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard-test-helpers.js","sourceRoot":"","sources":["../../../src/tests/helpers/dashboard-test-helpers.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crap",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Deterministic QA plugin for Claude Code — CRAP index, Technical Debt Ratio, tree-sitter AST, SARIF 2.1.0, hooks, and a local Vue dashboard.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://code.claude.com/schemas/plugin.json",
3
3
  "name": "claude-crap",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "description": "Deterministic Quality Assurance plugin for Claude Code. Wraps every Write / Edit / Bash tool call with a PreToolUse gatekeeper, a PostToolUse verifier, and a Stop quality gate backed by CRAP index, Technical Debt Ratio, tree-sitter AST metrics, and SARIF 2.1.0 reports. Forbids the agent from writing functional code before a test safety net exists.",
6
6
  "author": {
7
7
  "name": "Alan Hernandez",
@@ -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
  };
@@ -7458,6 +7458,23 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
7458
7458
  // CI artefact staging, Maven
7459
7459
  "publish",
7460
7460
  // `dotnet publish` output
7461
+ // Test coverage report bundles (generated HTML/JS from coverage tools;
7462
+ // walking them floods the complexity scanner with synthetic minified
7463
+ // functions like `coverage-report/main.js::gG` at CC 80+).
7464
+ "coverage-report",
7465
+ // ReportGenerator default (.NET)
7466
+ "CoverageReport",
7467
+ // ReportGenerator PascalCase variant
7468
+ "coveragereport",
7469
+ // ReportGenerator lowercase fallback
7470
+ "TestResults",
7471
+ // `dotnet test` default output
7472
+ "cobertura",
7473
+ // Cobertura XML reporter
7474
+ "lcov-report",
7475
+ // Istanbul HTML reporter
7476
+ "htmlcov",
7477
+ // coverage.py HTML output
7461
7478
  // Desktop / mobile packaging outputs
7462
7479
  "dist-electron",
7463
7480
  // Electron-builder
@@ -7835,6 +7852,7 @@ async function buildFileDetail(input) {
7835
7852
  ) / 100 : 0;
7836
7853
  return {
7837
7854
  filePath: relativePath,
7855
+ absolutePath,
7838
7856
  language,
7839
7857
  physicalLoc,
7840
7858
  logicalLoc,
@@ -7857,6 +7875,20 @@ async function buildFileDetail(input) {
7857
7875
  // src/dashboard/server.ts
7858
7876
  async function startDashboard(options) {
7859
7877
  const { config, sarifStore, workspaceStatsProvider, logger: logger2 } = options;
7878
+ const pidFilePath = resolvePidFilePath(config);
7879
+ const adoption = await tryAdoptExisting(pidFilePath, config.dashboardPort, logger2);
7880
+ if (adoption) {
7881
+ logger2.info(
7882
+ { url: adoption.url, ownerPid: adoption.pid, port: config.dashboardPort },
7883
+ "adopted existing claude-crap dashboard"
7884
+ );
7885
+ return {
7886
+ url: adoption.url,
7887
+ adopted: true,
7888
+ async close() {
7889
+ }
7890
+ };
7891
+ }
7860
7892
  const publicRoot = await resolvePublicRoot(logger2);
7861
7893
  const fastify = Fastify({
7862
7894
  logger: false,
@@ -7909,14 +7941,35 @@ async function startDashboard(options) {
7909
7941
  fastify.get("/", async (_request, reply) => {
7910
7942
  return reply.sendFile("index.html");
7911
7943
  });
7912
- const pidFilePath = resolvePidFilePath(config);
7913
- await killStaleDashboard(pidFilePath, config.dashboardPort, logger2);
7914
- await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
7944
+ try {
7945
+ await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
7946
+ } catch (err) {
7947
+ const code = err.code;
7948
+ if (code === "EADDRINUSE") {
7949
+ await fastify.close().catch(() => {
7950
+ });
7951
+ const raceAdoption = await tryAdoptExisting(pidFilePath, config.dashboardPort, logger2);
7952
+ if (raceAdoption) {
7953
+ logger2.info(
7954
+ { url: raceAdoption.url, ownerPid: raceAdoption.pid, port: config.dashboardPort },
7955
+ "dashboard bind lost race, adopted concurrent owner"
7956
+ );
7957
+ return {
7958
+ url: raceAdoption.url,
7959
+ adopted: true,
7960
+ async close() {
7961
+ }
7962
+ };
7963
+ }
7964
+ }
7965
+ throw err;
7966
+ }
7915
7967
  const url = `http://127.0.0.1:${config.dashboardPort}`;
7916
7968
  logger2.info({ url, publicRoot }, "claude-crap dashboard listening");
7917
7969
  writePidFile(pidFilePath, config.dashboardPort);
7918
7970
  return {
7919
7971
  url,
7972
+ adopted: false,
7920
7973
  async close() {
7921
7974
  removePidFile(pidFilePath);
7922
7975
  await fastify.close();
@@ -7986,29 +8039,40 @@ function isPidAlive(pid) {
7986
8039
  return false;
7987
8040
  }
7988
8041
  }
7989
- async function killStaleDashboard(pidFilePath, port, logger2) {
7990
- if (!existsSync(pidFilePath)) return;
8042
+ async function tryAdoptExisting(pidFilePath, port, logger2) {
8043
+ if (!existsSync(pidFilePath)) return null;
7991
8044
  let stale;
7992
8045
  try {
7993
8046
  stale = JSON.parse(readFileSync(pidFilePath, "utf8"));
7994
8047
  } catch {
8048
+ logger2.info({ pidFilePath }, "corrupt dashboard pidfile, removing");
7995
8049
  removePidFile(pidFilePath);
7996
- return;
8050
+ return null;
7997
8051
  }
7998
8052
  if (!isPidAlive(stale.pid)) {
7999
- logger2.info({ stalePid: stale.pid }, "stale dashboard PID file found (process dead), removing");
8053
+ logger2.info({ stalePid: stale.pid }, "stale dashboard pidfile (process dead), removing");
8000
8054
  removePidFile(pidFilePath);
8001
- return;
8055
+ return null;
8002
8056
  }
8003
- logger2.info(
8004
- { stalePid: stale.pid, port: stale.port, startedAt: stale.startedAt },
8005
- "killing stale dashboard process from previous session"
8057
+ if (stale.port !== port) {
8058
+ logger2.info(
8059
+ { stalePort: stale.port, wantedPort: port },
8060
+ "dashboard pidfile points at different port, ignoring"
8061
+ );
8062
+ removePidFile(pidFilePath);
8063
+ return null;
8064
+ }
8065
+ const healthy = await probeDashboardHealth(port);
8066
+ if (healthy) {
8067
+ return { url: `http://127.0.0.1:${port}`, pid: stale.pid };
8068
+ }
8069
+ logger2.warn(
8070
+ { stalePid: stale.pid, port },
8071
+ "dashboard pidfile owner is unresponsive, terminating"
8006
8072
  );
8007
8073
  try {
8008
8074
  process.kill(stale.pid, "SIGTERM");
8009
8075
  } catch {
8010
- removePidFile(pidFilePath);
8011
- return;
8012
8076
  }
8013
8077
  for (let i = 0; i < 30; i++) {
8014
8078
  if (!isPidAlive(stale.pid)) break;
@@ -8023,6 +8087,17 @@ async function killStaleDashboard(pidFilePath, port, logger2) {
8023
8087
  }
8024
8088
  removePidFile(pidFilePath);
8025
8089
  await new Promise((r) => setTimeout(r, 300));
8090
+ return null;
8091
+ }
8092
+ async function probeDashboardHealth(port) {
8093
+ try {
8094
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
8095
+ signal: AbortSignal.timeout(500)
8096
+ });
8097
+ return res.ok;
8098
+ } catch {
8099
+ return false;
8100
+ }
8026
8101
  }
8027
8102
  async function buildComplexityReport(config, engine, logger2, exclude) {
8028
8103
  const threshold = config.cyclomaticMax;