claude-crap 0.4.5 → 0.4.7
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 +25 -0
- package/README.md +22 -25
- 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/monorepo/project-map.d.ts.map +1 -1
- package/dist/monorepo/project-map.js +135 -6
- package/dist/monorepo/project-map.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +2 -2
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/shared/exclusions.d.ts.map +1 -1
- package/dist/shared/exclusions.js +22 -0
- package/dist/shared/exclusions.js.map +1 -1
- 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 +145 -31
- package/plugin/bundle/mcp-server.mjs.map +3 -3
- package/plugin/hooks/lib/gatekeeper-rules.mjs +274 -45
- package/plugin/hooks/lib/quality-gate.mjs +3 -0
- package/plugin/package-lock.json +8 -8
- package/plugin/package.json +1 -1
- package/src/dashboard/file-detail.ts +7 -0
- package/src/dashboard/public/index.html +216 -7
- package/src/monorepo/project-map.ts +144 -6
- package/src/scanner/bootstrap.ts +7 -2
- package/src/shared/exclusions.ts +26 -0
- package/src/tests/exclusions.test.ts +53 -0
- package/src/tests/file-detail-api.test.ts +38 -0
- package/src/tests/gatekeeper-rules.test.ts +173 -0
- package/src/tests/project-map.test.ts +216 -0
- package/src/tests/workspace-walker.test.ts +94 -0
|
@@ -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
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
32
32
|
import { promises as fs } from "node:fs";
|
|
33
|
-
import { join, basename, resolve } from "node:path";
|
|
33
|
+
import { join, basename, resolve, relative, isAbsolute } from "node:path";
|
|
34
34
|
import { execFile } from "node:child_process";
|
|
35
35
|
|
|
36
36
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
@@ -170,11 +170,17 @@ function detectProjectType(dir: string): ProjectType {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// C# — check the well-known single-file marker first, then scan for
|
|
173
|
-
// per-project extension files (.csproj / .sln) at this level
|
|
173
|
+
// per-project extension files (.csproj / .sln / .slnx) at this level
|
|
174
|
+
// only. .slnx is the XML solution format introduced with .NET 9.
|
|
174
175
|
if (has("Directory.Build.props")) return "csharp";
|
|
175
176
|
try {
|
|
176
177
|
const entries = readdirSync(dir);
|
|
177
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
entries.some(
|
|
180
|
+
(e) =>
|
|
181
|
+
e.endsWith(".csproj") || e.endsWith(".sln") || e.endsWith(".slnx"),
|
|
182
|
+
)
|
|
183
|
+
) {
|
|
178
184
|
return "csharp";
|
|
179
185
|
}
|
|
180
186
|
} catch {
|
|
@@ -210,6 +216,88 @@ function extractWorkspacePatterns(workspaces: unknown): string[] {
|
|
|
210
216
|
return [];
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Parse a minimal pnpm-workspace.yaml into a list of package patterns.
|
|
221
|
+
*
|
|
222
|
+
* Supports the shape the pnpm CLI actually emits and documents:
|
|
223
|
+
*
|
|
224
|
+
* packages:
|
|
225
|
+
* - "apps/*"
|
|
226
|
+
* - 'clients/mobile'
|
|
227
|
+
* - tooling/cli
|
|
228
|
+
*
|
|
229
|
+
* Deliberately a bespoke parser: a full YAML engine is a large
|
|
230
|
+
* dependency for a single configuration file, and pnpm's own format is
|
|
231
|
+
* a narrow subset. Unsupported constructs (anchors, flow sequences,
|
|
232
|
+
* nested mappings) fall through as "no patterns", which in turn lets
|
|
233
|
+
* the caller fall back to the conventional directory scan.
|
|
234
|
+
*
|
|
235
|
+
* @param yaml Raw contents of a pnpm-workspace.yaml file.
|
|
236
|
+
* @returns Array of package patterns, or an empty array.
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* Strip a `#` inline comment from a YAML line while respecting single-
|
|
240
|
+
* and double-quoted scalars. Anchors, block scalars, and escape sequences
|
|
241
|
+
* beyond `\"` are out of scope — pnpm-workspace.yaml never uses them.
|
|
242
|
+
*/
|
|
243
|
+
function stripYamlComment(line: string): string {
|
|
244
|
+
let inSingle = false;
|
|
245
|
+
let inDouble = false;
|
|
246
|
+
for (let i = 0; i < line.length; i++) {
|
|
247
|
+
const ch = line[i];
|
|
248
|
+
if (ch === "\\" && inDouble && i + 1 < line.length) {
|
|
249
|
+
i++; // skip the escaped character inside a double-quoted scalar
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!inDouble && ch === "'") {
|
|
253
|
+
inSingle = !inSingle;
|
|
254
|
+
} else if (!inSingle && ch === '"') {
|
|
255
|
+
inDouble = !inDouble;
|
|
256
|
+
} else if (!inSingle && !inDouble && ch === "#") {
|
|
257
|
+
return line.slice(0, i);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return line;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parsePnpmWorkspaceYaml(yaml: string): string[] {
|
|
264
|
+
const patterns: string[] = [];
|
|
265
|
+
const lines = yaml.split(/\r?\n/);
|
|
266
|
+
let inPackages = false;
|
|
267
|
+
|
|
268
|
+
for (const rawLine of lines) {
|
|
269
|
+
// Strip inline comments — but only when `#` is outside quotes, so a
|
|
270
|
+
// valid entry like `"packages/#tools"` survives.
|
|
271
|
+
const line = stripYamlComment(rawLine).replace(/\s+$/, "");
|
|
272
|
+
if (line.length === 0) continue;
|
|
273
|
+
|
|
274
|
+
// Top-level key "packages:" starts the list.
|
|
275
|
+
if (/^packages\s*:\s*$/.test(line)) {
|
|
276
|
+
inPackages = true;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Any other top-level key ends the packages block.
|
|
281
|
+
if (inPackages && /^[^\s-]/.test(line)) {
|
|
282
|
+
inPackages = false;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!inPackages) continue;
|
|
287
|
+
|
|
288
|
+
// List item: " - value" with optional single/double quotes. The
|
|
289
|
+
// bare-word branch can now safely include `#`, because inline
|
|
290
|
+
// comments are already stripped by stripYamlComment() above.
|
|
291
|
+
const m = /^\s*-\s*("([^"]*)"|'([^']*)'|(\S+))\s*$/.exec(line);
|
|
292
|
+
if (m) {
|
|
293
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
294
|
+
if (value.length > 0) patterns.push(value);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return patterns;
|
|
299
|
+
}
|
|
300
|
+
|
|
213
301
|
/**
|
|
214
302
|
* Expand a single workspace glob pattern into matching absolute paths.
|
|
215
303
|
*
|
|
@@ -225,14 +313,24 @@ function expandWorkspacePattern(
|
|
|
225
313
|
workspaceRoot: string,
|
|
226
314
|
pattern: string,
|
|
227
315
|
): string[] {
|
|
316
|
+
// Guard against patterns that escape the workspace root (e.g.
|
|
317
|
+
// `../shared/*` in pnpm-workspace.yaml). External paths are dropped
|
|
318
|
+
// silently so a misconfigured manifest cannot widen the scan scope.
|
|
319
|
+
const isInsideWorkspace = (candidate: string): boolean => {
|
|
320
|
+
const rel = relative(workspaceRoot, candidate);
|
|
321
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
322
|
+
};
|
|
323
|
+
|
|
228
324
|
if (pattern.endsWith("/*")) {
|
|
229
325
|
// Glob: list one level of the parent directory.
|
|
230
326
|
const parentDir = join(workspaceRoot, pattern.slice(0, -2));
|
|
327
|
+
if (!isInsideWorkspace(parentDir)) return [];
|
|
231
328
|
try {
|
|
232
329
|
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
233
330
|
return entries
|
|
234
331
|
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
235
|
-
.map((e) => join(parentDir, e.name))
|
|
332
|
+
.map((e) => join(parentDir, e.name))
|
|
333
|
+
.filter(isInsideWorkspace);
|
|
236
334
|
} catch {
|
|
237
335
|
return [];
|
|
238
336
|
}
|
|
@@ -240,6 +338,7 @@ function expandWorkspacePattern(
|
|
|
240
338
|
|
|
241
339
|
// Plain path — verify it exists and is a directory.
|
|
242
340
|
const full = resolve(workspaceRoot, pattern);
|
|
341
|
+
if (!isInsideWorkspace(full)) return [];
|
|
243
342
|
try {
|
|
244
343
|
const entries = readdirSync(full, { withFileTypes: true });
|
|
245
344
|
// readdirSync succeeds only for directories; if we got here it exists.
|
|
@@ -288,6 +387,24 @@ function collectSubdirectories(
|
|
|
288
387
|
}
|
|
289
388
|
}
|
|
290
389
|
|
|
390
|
+
// 1b. pnpm workspaces — package.json does not carry a `workspaces`
|
|
391
|
+
// field under pnpm; the source of truth is pnpm-workspace.yaml.
|
|
392
|
+
const pnpmPath = join(workspaceRoot, "pnpm-workspace.yaml");
|
|
393
|
+
if (existsSync(pnpmPath)) {
|
|
394
|
+
try {
|
|
395
|
+
const raw = readFileSync(pnpmPath, "utf-8");
|
|
396
|
+
const patterns = parsePnpmWorkspaceYaml(raw);
|
|
397
|
+
for (const pattern of patterns) {
|
|
398
|
+
for (const absPath of expandWorkspacePattern(workspaceRoot, pattern)) {
|
|
399
|
+
subdirs.add(absPath);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// Read error — skip pnpm workspaces source. The parser itself
|
|
404
|
+
// never throws; malformed content just yields an empty array.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
291
408
|
// 2. User-configured projectDirs from .claude-crap.json (highest priority).
|
|
292
409
|
// These can be parent directories scanned one level deep (e.g. "apps")
|
|
293
410
|
// or direct project paths (e.g. "tools/cli").
|
|
@@ -297,8 +414,7 @@ function collectSubdirectories(
|
|
|
297
414
|
if (!existsSync(absDir)) continue;
|
|
298
415
|
|
|
299
416
|
// If the directory itself has a project marker, treat it as a project.
|
|
300
|
-
|
|
301
|
-
if (hasMarker) {
|
|
417
|
+
if (directoryIsProjectRoot(absDir)) {
|
|
302
418
|
subdirs.add(absDir);
|
|
303
419
|
continue;
|
|
304
420
|
}
|
|
@@ -344,6 +460,28 @@ const PROJECT_MARKERS = [
|
|
|
344
460
|
"pom.xml", "build.gradle", "build.gradle.kts", "Directory.Build.props",
|
|
345
461
|
];
|
|
346
462
|
|
|
463
|
+
/** Per-project file extensions that indicate a .NET project root. */
|
|
464
|
+
const DOTNET_PROJECT_EXTENSIONS = [".csproj", ".sln", ".slnx"] as const;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Return true when `absDir` looks like a project root — either because
|
|
468
|
+
* it carries one of the well-known {@link PROJECT_MARKERS} single-file
|
|
469
|
+
* markers, or because it contains a .NET per-project file
|
|
470
|
+
* (`.csproj` / `.sln` / `.slnx`). The .NET branch is separate because
|
|
471
|
+
* those markers use extensions rather than fixed filenames.
|
|
472
|
+
*/
|
|
473
|
+
function directoryIsProjectRoot(absDir: string): boolean {
|
|
474
|
+
if (PROJECT_MARKERS.some((m) => existsSync(join(absDir, m)))) return true;
|
|
475
|
+
try {
|
|
476
|
+
const entries = readdirSync(absDir);
|
|
477
|
+
return entries.some((e) =>
|
|
478
|
+
DOTNET_PROJECT_EXTENSIONS.some((ext) => e.endsWith(ext)),
|
|
479
|
+
);
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
347
485
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
348
486
|
|
|
349
487
|
/**
|
package/src/scanner/bootstrap.ts
CHANGED
|
@@ -107,11 +107,16 @@ export function detectProjectType(workspaceRoot: string): ProjectType {
|
|
|
107
107
|
return "java";
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// C# detection
|
|
110
|
+
// C# detection — .slnx is the XML solution format introduced with .NET 9.
|
|
111
111
|
if (has("Directory.Build.props")) return "csharp";
|
|
112
112
|
try {
|
|
113
113
|
const entries = readdirSync(workspaceRoot);
|
|
114
|
-
if (
|
|
114
|
+
if (
|
|
115
|
+
entries.some(
|
|
116
|
+
(e) =>
|
|
117
|
+
e.endsWith(".csproj") || e.endsWith(".sln") || e.endsWith(".slnx"),
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
115
120
|
return "csharp";
|
|
116
121
|
}
|
|
117
122
|
} catch {
|
package/src/shared/exclusions.ts
CHANGED
|
@@ -37,6 +37,32 @@ export const DEFAULT_SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
|
37
37
|
"out",
|
|
38
38
|
"target",
|
|
39
39
|
"coverage",
|
|
40
|
+
"artifacts", // CI artefact staging, Maven
|
|
41
|
+
"publish", // `dotnet publish` output
|
|
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
|
+
|
|
54
|
+
// Desktop / mobile packaging outputs
|
|
55
|
+
"dist-electron",// Electron-builder
|
|
56
|
+
"release", // Electron-builder, Tauri
|
|
57
|
+
|
|
58
|
+
// .NET per-project build outputs (conventional at any depth)
|
|
59
|
+
"bin",
|
|
60
|
+
"obj",
|
|
61
|
+
|
|
62
|
+
// iOS / macOS dependency + build caches
|
|
63
|
+
"Pods", // CocoaPods
|
|
64
|
+
"DerivedData", // Xcode
|
|
65
|
+
"Carthage", // Swift
|
|
40
66
|
|
|
41
67
|
// Framework build outputs
|
|
42
68
|
".next", // Next.js
|
|
@@ -31,6 +31,44 @@ describe("DEFAULT_SKIP_DIRS", () => {
|
|
|
31
31
|
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing: ${dir}`);
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it("includes Electron/CI/iOS/.NET build outputs", () => {
|
|
36
|
+
// These directories host compiled or packaged artefacts; counting
|
|
37
|
+
// them inflates the LOC denominator of the TDR on every real
|
|
38
|
+
// Electron, Xcode, or .NET workspace.
|
|
39
|
+
for (const dir of [
|
|
40
|
+
"dist-electron", // Electron-builder
|
|
41
|
+
"release", // Electron-builder, Tauri
|
|
42
|
+
"artifacts", // CI, Maven
|
|
43
|
+
"publish", // dotnet publish
|
|
44
|
+
"bin", // .NET build
|
|
45
|
+
"obj", // .NET build
|
|
46
|
+
"Pods", // CocoaPods
|
|
47
|
+
"DerivedData", // Xcode
|
|
48
|
+
"Carthage", // Swift
|
|
49
|
+
]) {
|
|
50
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing skip dir: ${dir}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("includes coverage report directories emitted by test tooling", () => {
|
|
55
|
+
// ReportGenerator (.NET), Istanbul (JS) and dotCover emit generated
|
|
56
|
+
// HTML/JS bundles into these folders. Previously only the bare
|
|
57
|
+
// `coverage` name was skipped, which leaked files like
|
|
58
|
+
// `GanttLite.Server/coverage-report/main.js` into complexity scans
|
|
59
|
+
// and flooded the dashboard with false-positive high-CC findings.
|
|
60
|
+
for (const dir of [
|
|
61
|
+
"coverage-report", // ReportGenerator default
|
|
62
|
+
"CoverageReport", // ReportGenerator PascalCase
|
|
63
|
+
"coveragereport", // ReportGenerator lowercase fallback
|
|
64
|
+
"TestResults", // dotnet test default
|
|
65
|
+
"cobertura", // Cobertura XML output
|
|
66
|
+
"lcov-report", // Istanbul HTML reporter
|
|
67
|
+
"htmlcov", // coverage.py HTML output
|
|
68
|
+
]) {
|
|
69
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing coverage skip dir: ${dir}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
34
72
|
});
|
|
35
73
|
|
|
36
74
|
describe("DEFAULT_SKIP_PATTERNS", () => {
|
|
@@ -74,6 +112,21 @@ describe("createExclusionFilter", () => {
|
|
|
74
112
|
assert.equal(filter.shouldSkipDir("generated"), true);
|
|
75
113
|
assert.equal(filter.shouldSkipDir("src"), false);
|
|
76
114
|
});
|
|
115
|
+
|
|
116
|
+
it("skips coverage report directories from every major test runner", () => {
|
|
117
|
+
// Regression: the ReportGenerator bundle
|
|
118
|
+
// (`coverage-report/main.js`, `class.js`, etc.) used to surface
|
|
119
|
+
// in the dashboard's complexity hotspots as minified code with
|
|
120
|
+
// CC ≥ 80. These directories must never be walked.
|
|
121
|
+
const filter = createExclusionFilter();
|
|
122
|
+
assert.equal(filter.shouldSkipDir("coverage-report"), true);
|
|
123
|
+
assert.equal(filter.shouldSkipDir("CoverageReport"), true);
|
|
124
|
+
assert.equal(filter.shouldSkipDir("coveragereport"), true);
|
|
125
|
+
assert.equal(filter.shouldSkipDir("TestResults"), true);
|
|
126
|
+
assert.equal(filter.shouldSkipDir("cobertura"), true);
|
|
127
|
+
assert.equal(filter.shouldSkipDir("lcov-report"), true);
|
|
128
|
+
assert.equal(filter.shouldSkipDir("htmlcov"), true);
|
|
129
|
+
});
|
|
77
130
|
});
|
|
78
131
|
|
|
79
132
|
describe("shouldSkipFile", () => {
|
|
@@ -190,6 +190,44 @@ describe("buildFileDetail", () => {
|
|
|
190
190
|
}
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it("returns the resolved absolute path so the UI can build editor deep-links", async () => {
|
|
194
|
+
// The file-detail view exposes "Open in editor" buttons that emit
|
|
195
|
+
// `vscode://file/{absolutePath}:{line}` (and JetBrains equivalents).
|
|
196
|
+
// Constructing that URL client-side requires the absolute path —
|
|
197
|
+
// the workspace-relative `filePath` alone is not enough. This
|
|
198
|
+
// characterization test pins that contract so the UI can rely on it.
|
|
199
|
+
const dir = makeTmpDir();
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(join(dir, "hello.ts"), SAMPLE_TS);
|
|
202
|
+
const store = new SarifStore({
|
|
203
|
+
workspaceRoot: dir,
|
|
204
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
205
|
+
});
|
|
206
|
+
const result = await buildFileDetail({
|
|
207
|
+
relativePath: "hello.ts",
|
|
208
|
+
workspaceRoot: dir,
|
|
209
|
+
astEngine: engine,
|
|
210
|
+
sarifStore: store,
|
|
211
|
+
cyclomaticMax: 15,
|
|
212
|
+
});
|
|
213
|
+
assert.equal(
|
|
214
|
+
result.absolutePath,
|
|
215
|
+
join(dir, "hello.ts"),
|
|
216
|
+
`expected absolutePath to join workspaceRoot with filePath, got ${result.absolutePath}`,
|
|
217
|
+
);
|
|
218
|
+
assert.ok(
|
|
219
|
+
result.absolutePath.endsWith("hello.ts"),
|
|
220
|
+
"absolutePath should end with the relative path",
|
|
221
|
+
);
|
|
222
|
+
assert.ok(
|
|
223
|
+
result.absolutePath.startsWith(dir),
|
|
224
|
+
"absolutePath must live under the workspace root (path-traversal defense)",
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
193
231
|
it("returns empty functions for unsupported languages", async () => {
|
|
194
232
|
const dir = makeTmpDir();
|
|
195
233
|
try {
|