@wbern/obscene 1.1.0 → 1.3.0
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/dist/cli.js +143 -57
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,17 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/analyze.ts
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
8
|
import { readFileSync } from "fs";
|
|
9
|
+
var IGNORE_FILES = [".obsignore", ".obsceneignore"];
|
|
10
|
+
function readIgnoreFile() {
|
|
11
|
+
for (const name of IGNORE_FILES) {
|
|
12
|
+
try {
|
|
13
|
+
const content = readFileSync(name, "utf-8");
|
|
14
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
9
20
|
var DEFAULT_EXCLUDES = [
|
|
10
21
|
/\.test\./,
|
|
11
22
|
/\.spec\./,
|
|
@@ -18,8 +29,10 @@ var DEFAULT_EXCLUDES = [
|
|
|
18
29
|
/\.stories\./,
|
|
19
30
|
/\.d\.ts$/
|
|
20
31
|
];
|
|
21
|
-
var
|
|
22
|
-
var
|
|
32
|
+
var HOT_CUMULATIVE = 0.5;
|
|
33
|
+
var WARM_CUMULATIVE = 0.8;
|
|
34
|
+
var MIN_FIX_COMMITS = 5;
|
|
35
|
+
var MIN_FILES_WITH_FIXES = 3;
|
|
23
36
|
function isExcluded(location, patterns) {
|
|
24
37
|
return patterns.some((p) => p.test(location));
|
|
25
38
|
}
|
|
@@ -111,7 +124,7 @@ function getAuthors(months) {
|
|
|
111
124
|
if (!block.trim()) continue;
|
|
112
125
|
const lines = block.split("\n");
|
|
113
126
|
const author = lines[0].trim();
|
|
114
|
-
if (!author) continue;
|
|
127
|
+
if (!author || author.endsWith("[bot]")) continue;
|
|
115
128
|
for (let i = 1; i < lines.length; i++) {
|
|
116
129
|
const file = normalizePath(lines[i].trim());
|
|
117
130
|
if (!file) continue;
|
|
@@ -174,12 +187,12 @@ function assignTiers(items, totalScore) {
|
|
|
174
187
|
item.percentOfTotal = Math.round(item.score / totalScore * 1e3) / 10;
|
|
175
188
|
cumulative += item.score;
|
|
176
189
|
const cumulativeShare = cumulative / totalScore;
|
|
177
|
-
if (cumulativeShare <=
|
|
178
|
-
item.tier = "
|
|
179
|
-
} else if (cumulativeShare <=
|
|
180
|
-
item.tier = "
|
|
190
|
+
if (cumulativeShare <= HOT_CUMULATIVE) {
|
|
191
|
+
item.tier = "hot";
|
|
192
|
+
} else if (cumulativeShare <= WARM_CUMULATIVE) {
|
|
193
|
+
item.tier = "warm";
|
|
181
194
|
} else {
|
|
182
|
-
item.tier = "
|
|
195
|
+
item.tier = "cool";
|
|
183
196
|
}
|
|
184
197
|
}
|
|
185
198
|
}
|
|
@@ -213,7 +226,7 @@ function computeRanking(files, churn, metricExtractor, densityExtractor) {
|
|
|
213
226
|
file: f.file,
|
|
214
227
|
score: metricValue * fileChurn,
|
|
215
228
|
percentOfTotal: 0,
|
|
216
|
-
tier: "
|
|
229
|
+
tier: "cool",
|
|
217
230
|
churn: fileChurn,
|
|
218
231
|
metricValue,
|
|
219
232
|
metricDensity: densityExtractor ? densityExtractor(f) : void 0
|
|
@@ -244,16 +257,35 @@ function computeAllRankings(files, churn, defects, nestingDepths, authors, top)
|
|
|
244
257
|
extract: (f) => authors.get(f.file) ?? 0
|
|
245
258
|
}
|
|
246
259
|
};
|
|
260
|
+
const skipped = {};
|
|
261
|
+
const totalFixCommits = [...defects.values()].reduce((s, v) => s + v, 0);
|
|
262
|
+
const filesWithFixes = defects.size;
|
|
263
|
+
if (totalFixCommits < MIN_FIX_COMMITS || filesWithFixes < MIN_FILES_WITH_FIXES) {
|
|
264
|
+
skipped.defects = {
|
|
265
|
+
reason: `insufficient data (${totalFixCommits} fix: commits across ${filesWithFixes} files, need ${MIN_FIX_COMMITS}+ commits across ${MIN_FILES_WITH_FIXES}+ files)`,
|
|
266
|
+
suggestion: "Adopt conventional commits with fix: prefix. See conventionalcommits.org"
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
let maxAuthors = 0;
|
|
270
|
+
for (const count of authors.values()) {
|
|
271
|
+
if (count > maxAuthors) maxAuthors = count;
|
|
272
|
+
}
|
|
273
|
+
if (maxAuthors <= 1) {
|
|
274
|
+
skipped.authors = {
|
|
275
|
+
reason: "all files have the same author count \u2014 no variance to rank"
|
|
276
|
+
};
|
|
277
|
+
}
|
|
247
278
|
const rankings = {};
|
|
248
279
|
for (const def of RANKING_DEFS) {
|
|
280
|
+
if (skipped[def.key]) continue;
|
|
249
281
|
const ext = extractors[def.key];
|
|
250
282
|
const allEntries = computeRanking(files, churn, ext.extract, ext.density);
|
|
251
283
|
if (allEntries.length === 0) continue;
|
|
252
284
|
const limited = top > 0 ? allEntries.slice(0, top) : allEntries;
|
|
253
285
|
const tierCounts = {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
286
|
+
hot: 0,
|
|
287
|
+
warm: 0,
|
|
288
|
+
cool: 0
|
|
257
289
|
};
|
|
258
290
|
for (const e of allEntries) {
|
|
259
291
|
tierCounts[e.tier]++;
|
|
@@ -268,7 +300,7 @@ function computeAllRankings(files, churn, defects, nestingDepths, authors, top)
|
|
|
268
300
|
entries: limited
|
|
269
301
|
};
|
|
270
302
|
}
|
|
271
|
-
return rankings;
|
|
303
|
+
return { rankings, skipped };
|
|
272
304
|
}
|
|
273
305
|
function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
|
|
274
306
|
const entries = [];
|
|
@@ -286,7 +318,7 @@ function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
|
|
|
286
318
|
totalComplexity,
|
|
287
319
|
couplingScore: count,
|
|
288
320
|
percentOfTotal: 0,
|
|
289
|
-
tier: "
|
|
321
|
+
tier: "cool"
|
|
290
322
|
});
|
|
291
323
|
}
|
|
292
324
|
entries.sort((a, b) => b.couplingScore - a.couplingScore);
|
|
@@ -346,6 +378,7 @@ function getNestingDepths(filePaths) {
|
|
|
346
378
|
}
|
|
347
379
|
var RRF_K = 10;
|
|
348
380
|
function computeComposite(rankings, churn, top) {
|
|
381
|
+
const totalDimensions = Object.keys(rankings).length;
|
|
349
382
|
const fileScores = /* @__PURE__ */ new Map();
|
|
350
383
|
for (const ranking of Object.values(rankings)) {
|
|
351
384
|
for (let i = 0; i < ranking.entries.length; i++) {
|
|
@@ -366,7 +399,7 @@ function computeComposite(rankings, churn, top) {
|
|
|
366
399
|
file,
|
|
367
400
|
score: Math.round(data.score * 1e4) / 1e4,
|
|
368
401
|
percentOfTotal: 0,
|
|
369
|
-
tier: "
|
|
402
|
+
tier: "cool",
|
|
370
403
|
churn: churn.get(file) ?? 0,
|
|
371
404
|
dimensionCount: data.dims
|
|
372
405
|
});
|
|
@@ -378,7 +411,8 @@ function computeComposite(rankings, churn, top) {
|
|
|
378
411
|
label: "Combined",
|
|
379
412
|
scoreFormula: "reciprocal rank fusion across all dimensions",
|
|
380
413
|
totalScore: 0,
|
|
381
|
-
tierCounts: {
|
|
414
|
+
tierCounts: { hot: 0, warm: 0, cool: 0 },
|
|
415
|
+
totalDimensions,
|
|
382
416
|
totalEntries: 0,
|
|
383
417
|
showing: 0,
|
|
384
418
|
entries: []
|
|
@@ -386,7 +420,7 @@ function computeComposite(rankings, churn, top) {
|
|
|
386
420
|
}
|
|
387
421
|
assignTiers(entries, totalScore);
|
|
388
422
|
const limited = top > 0 ? entries.slice(0, top) : entries;
|
|
389
|
-
const tierCounts = {
|
|
423
|
+
const tierCounts = { hot: 0, warm: 0, cool: 0 };
|
|
390
424
|
for (const e of entries) {
|
|
391
425
|
tierCounts[e.tier]++;
|
|
392
426
|
}
|
|
@@ -395,12 +429,16 @@ function computeComposite(rankings, churn, top) {
|
|
|
395
429
|
scoreFormula: "reciprocal rank fusion across all dimensions",
|
|
396
430
|
totalScore: Math.round(totalScore * 1e4) / 1e4,
|
|
397
431
|
tierCounts,
|
|
432
|
+
totalDimensions,
|
|
398
433
|
totalEntries: entries.length,
|
|
399
434
|
showing: limited.length,
|
|
400
435
|
entries: limited
|
|
401
436
|
};
|
|
402
437
|
}
|
|
403
438
|
|
|
439
|
+
// src/format.ts
|
|
440
|
+
import pc2 from "picocolors";
|
|
441
|
+
|
|
404
442
|
// src/color.ts
|
|
405
443
|
import pc from "picocolors";
|
|
406
444
|
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
@@ -412,7 +450,8 @@ function isWide(cp) {
|
|
|
412
450
|
cp >= 13312 && cp <= 40959 || // Hangul Syllables (U+AC00–U+D7AF)
|
|
413
451
|
cp >= 44032 && cp <= 55215 || // CJK Compatibility Ideographs (U+F900–U+FAFF)
|
|
414
452
|
cp >= 63744 && cp <= 64255 || // Fullwidth Forms (U+FF01–U+FF60, U+FFE0–U+FFE6)
|
|
415
|
-
cp >= 65281 && cp <= 65376 || cp >= 65504 && cp <= 65510 || //
|
|
453
|
+
cp >= 65281 && cp <= 65376 || cp >= 65504 && cp <= 65510 || // Miscellaneous Symbols (U+2600–U+26FF) — includes ☀, ⚡, etc.
|
|
454
|
+
cp >= 9728 && cp <= 9983 || // Emoji and symbol blocks in supplementary planes (U+1F300–U+1FAFF)
|
|
416
455
|
cp >= 127744 && cp <= 129791 || // CJK Extension B+ and supplementary ideographs (U+20000–U+2FA1F)
|
|
417
456
|
cp >= 131072 && cp <= 195103
|
|
418
457
|
);
|
|
@@ -422,9 +461,8 @@ function visualWidth(s) {
|
|
|
422
461
|
let width = 0;
|
|
423
462
|
for (const ch of stripped) {
|
|
424
463
|
const cp = ch.codePointAt(0);
|
|
425
|
-
if (cp
|
|
426
|
-
|
|
427
|
-
}
|
|
464
|
+
if (cp === 65038 || cp === 65039) continue;
|
|
465
|
+
width += isWide(cp) ? 2 : 1;
|
|
428
466
|
}
|
|
429
467
|
return width;
|
|
430
468
|
}
|
|
@@ -440,19 +478,19 @@ function truncate(s, max) {
|
|
|
440
478
|
return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
|
|
441
479
|
}
|
|
442
480
|
function tierLabel(tier) {
|
|
443
|
-
if (tier === "
|
|
444
|
-
if (tier === "
|
|
445
|
-
return pc.
|
|
481
|
+
if (tier === "hot") return pc.red("\u{1F525} HOT ");
|
|
482
|
+
if (tier === "warm") return pc.yellow("\u2600\uFE0F WARM");
|
|
483
|
+
return pc.blue("\u{1F9CA} COOL");
|
|
446
484
|
}
|
|
447
485
|
function colorRow(tier, text) {
|
|
448
|
-
if (tier === "
|
|
449
|
-
if (tier === "
|
|
450
|
-
return pc.
|
|
486
|
+
if (tier === "hot") return pc.red(text);
|
|
487
|
+
if (tier === "warm") return pc.yellow(text);
|
|
488
|
+
return pc.blue(text);
|
|
451
489
|
}
|
|
452
490
|
function tierSummary(tierCounts, showing, total) {
|
|
453
491
|
const lines = [];
|
|
454
492
|
lines.push(
|
|
455
|
-
`Tiers: ${pc.red(`${tierCounts.
|
|
493
|
+
`Tiers: ${pc.red(`${tierCounts.hot} HOT`)}, ${pc.yellow(`${tierCounts.warm} WARM`)}, ${pc.blue(`${tierCounts.cool} COOL`)}`
|
|
456
494
|
);
|
|
457
495
|
lines.push(`Showing: ${showing} of ${total}`);
|
|
458
496
|
return lines;
|
|
@@ -480,12 +518,16 @@ function formatReportTable(output) {
|
|
|
480
518
|
}
|
|
481
519
|
lines.push("");
|
|
482
520
|
lines.push(
|
|
483
|
-
|
|
521
|
+
pc2.dim(
|
|
522
|
+
"Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
|
|
523
|
+
)
|
|
484
524
|
);
|
|
485
525
|
lines.push(
|
|
486
|
-
|
|
526
|
+
pc2.dim(
|
|
527
|
+
"High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values."
|
|
528
|
+
)
|
|
487
529
|
);
|
|
488
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
530
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
489
531
|
return lines.join("\n");
|
|
490
532
|
}
|
|
491
533
|
function getRankingColumns(key) {
|
|
@@ -569,12 +611,26 @@ function getRankingColumns(key) {
|
|
|
569
611
|
};
|
|
570
612
|
return [...base, ...metricCols[key] ?? [], tierCol];
|
|
571
613
|
}
|
|
572
|
-
|
|
614
|
+
var METRIC_EMOJI = {
|
|
615
|
+
complexity: "\u{1F9EC}",
|
|
616
|
+
nesting: "\u{1F4CF}",
|
|
617
|
+
defects: "\u{1F41B}",
|
|
618
|
+
authors: "\u{1F465}"
|
|
619
|
+
};
|
|
620
|
+
function formatRankingTable(key, ranking, description) {
|
|
573
621
|
const lines = [];
|
|
574
622
|
const cols = getRankingColumns(key);
|
|
623
|
+
const emoji = METRIC_EMOJI[key];
|
|
624
|
+
const prefix = emoji ? `${emoji} ` : "";
|
|
625
|
+
const title = ranking.label.toUpperCase().replace("CHURN", "\u{1F504} CHURN");
|
|
575
626
|
lines.push(
|
|
576
|
-
`${
|
|
627
|
+
`${prefix}${title} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
|
|
577
628
|
);
|
|
629
|
+
if (description) {
|
|
630
|
+
for (const line of description.split("\n")) {
|
|
631
|
+
lines.push(pc2.dim(line));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
578
634
|
lines.push(
|
|
579
635
|
...tierSummary(ranking.tierCounts, ranking.showing, ranking.totalEntries)
|
|
580
636
|
);
|
|
@@ -603,19 +659,35 @@ function formatHotspotsTable(output) {
|
|
|
603
659
|
const keys = Object.keys(rankings);
|
|
604
660
|
for (let i = 0; i < keys.length; i++) {
|
|
605
661
|
const key = keys[i];
|
|
606
|
-
lines.push(...formatRankingTable(key, rankings[key]));
|
|
662
|
+
lines.push(...formatRankingTable(key, rankings[key], output.guide[key]));
|
|
607
663
|
if (i < keys.length - 1) {
|
|
608
664
|
lines.push("");
|
|
665
|
+
lines.push("\xB7 \xB7 \xB7");
|
|
666
|
+
lines.push("");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (output.skipped) {
|
|
670
|
+
for (const [key, info] of Object.entries(output.skipped)) {
|
|
671
|
+
lines.push("");
|
|
672
|
+
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
|
673
|
+
lines.push(`${label} \xD7 Churn \u2014 skipped (${info.reason})`);
|
|
674
|
+
if (info.suggestion) {
|
|
675
|
+
lines.push(` ${info.suggestion}`);
|
|
676
|
+
}
|
|
609
677
|
}
|
|
610
678
|
}
|
|
611
679
|
lines.push("");
|
|
612
680
|
lines.push(
|
|
613
|
-
|
|
681
|
+
pc2.dim(
|
|
682
|
+
"Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
|
|
683
|
+
)
|
|
614
684
|
);
|
|
615
685
|
lines.push(
|
|
616
|
-
|
|
686
|
+
pc2.dim(
|
|
687
|
+
"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
|
|
688
|
+
)
|
|
617
689
|
);
|
|
618
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
690
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
619
691
|
return lines.join("\n");
|
|
620
692
|
}
|
|
621
693
|
function formatCouplingTable(output) {
|
|
@@ -635,21 +707,28 @@ function formatCouplingTable(output) {
|
|
|
635
707
|
}
|
|
636
708
|
lines.push("");
|
|
637
709
|
lines.push(
|
|
638
|
-
|
|
710
|
+
pc2.dim(
|
|
711
|
+
"Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files"
|
|
712
|
+
)
|
|
639
713
|
);
|
|
640
714
|
lines.push(
|
|
641
|
-
|
|
715
|
+
pc2.dim(
|
|
716
|
+
"Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine."
|
|
717
|
+
)
|
|
642
718
|
);
|
|
643
719
|
lines.push(
|
|
644
|
-
|
|
720
|
+
pc2.dim(
|
|
721
|
+
"Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown."
|
|
722
|
+
)
|
|
645
723
|
);
|
|
646
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
724
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
647
725
|
return lines.join("\n");
|
|
648
726
|
}
|
|
649
727
|
function formatCompositeTable(output) {
|
|
650
728
|
const lines = [];
|
|
729
|
+
lines.push("\u2550".repeat(84));
|
|
651
730
|
lines.push(
|
|
652
|
-
|
|
731
|
+
`\u2605 ${output.label.toUpperCase()} \u2014 Total score: ${output.totalScore.toLocaleString()}`
|
|
653
732
|
);
|
|
654
733
|
lines.push(
|
|
655
734
|
...tierSummary(output.tierCounts, output.showing, output.totalEntries)
|
|
@@ -660,7 +739,7 @@ function formatCompositeTable(output) {
|
|
|
660
739
|
);
|
|
661
740
|
lines.push("\u2500".repeat(84));
|
|
662
741
|
for (const entry of output.entries) {
|
|
663
|
-
const rawRow = padRight(truncate(entry.file, 48), 50) + padLeft(entry.score.toFixed(4), 9) + padLeft(String(entry.churn), 7) + padLeft(`${entry.dimensionCount}
|
|
742
|
+
const rawRow = padRight(truncate(entry.file, 48), 50) + padLeft(entry.score.toFixed(4), 9) + padLeft(String(entry.churn), 7) + padLeft(`${entry.dimensionCount}/${output.totalDimensions}`, 6) + padLeft(tierLabel(entry.tier), 12);
|
|
664
743
|
lines.push(colorRow(entry.tier, rawRow));
|
|
665
744
|
}
|
|
666
745
|
return lines.join("\n");
|
|
@@ -668,7 +747,7 @@ function formatCompositeTable(output) {
|
|
|
668
747
|
|
|
669
748
|
// src/cli.ts
|
|
670
749
|
var program = new Command();
|
|
671
|
-
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.
|
|
750
|
+
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.3.0");
|
|
672
751
|
var REPORT_GUIDE = {
|
|
673
752
|
complexity: "Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",
|
|
674
753
|
complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
|
|
@@ -676,18 +755,18 @@ var REPORT_GUIDE = {
|
|
|
676
755
|
};
|
|
677
756
|
var HOTSPOTS_GUIDE = {
|
|
678
757
|
rankings: "Four independent ranking tables, each scoring files by a different metric \xD7 churn. A file may rank high in one dimension but not others.",
|
|
679
|
-
complexity: "complexity \xD7 churn.
|
|
680
|
-
nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.",
|
|
681
|
-
defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may
|
|
682
|
-
authors: "authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.",
|
|
683
|
-
composite: "Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.",
|
|
684
|
-
tier: "Relative ranking within THIS codebase (top 50% =
|
|
758
|
+
complexity: "complexity \xD7 churn. Complex code that changes often poses maintenance risk.\nSource: McCabe cyclomatic complexity (1976) via scc \xB7 Strength: objective, language-agnostic \xB7 Limit: parsers and state machines score high naturally",
|
|
759
|
+
nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.\nSource: cognitive complexity research (SonarSource, G. Ann Campbell 2018) \xB7 Strength: catches hard-to-follow control flow \xB7 Limit: some patterns (error chains, config) legitimately nest deep",
|
|
760
|
+
defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may harbor latent bugs.\nSource: defect prediction via conventional commits (fix: prefix) \xB7 Strength: direct bug-history signal \xB7 Limit: requires consistent fix: convention to be accurate",
|
|
761
|
+
authors: "authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.\nSource: code ownership research (Bird et al. 2011, Microsoft) \xB7 Strength: flags diffuse ownership risk \xB7 Limit: doesn't measure expertise depth, bot authors filtered automatically",
|
|
762
|
+
composite: "Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.\nSource: RRF (Cormack et al. 2009) \xB7 Strength: robust to outliers, no normalization needed \xB7 Limit: equal weight across all dimensions",
|
|
763
|
+
tier: "Relative ranking within THIS codebase (top 50% = hot, next 30% = warm, bottom 20% = cool). NOT an absolute quality grade \u2014 a hot file is under heavy load, not necessarily broken."
|
|
685
764
|
};
|
|
686
765
|
var COUPLING_GUIDE = {
|
|
687
766
|
cochanges: "Times both files appeared in the same commit. Higher values suggest a dependency between the files. Same-directory pairs are excluded \u2014 only cross-directory pairs are shown.",
|
|
688
767
|
degree: "Percentage: shared commits / min(churn of file1, file2) \xD7 100. Shows how tightly coupled the pair is relative to their individual change rates. 100% means every change to the less-active file also touched the other.",
|
|
689
768
|
totalComplexity: "Sum of both files' cyclomatic complexity. Highlights coupled pairs where the involved code is also complex \u2014 hidden dependency + high complexity compounds maintenance risk.",
|
|
690
|
-
tier: "Relative ranking within THIS codebase's coupling pairs (top 50% =
|
|
769
|
+
tier: "Relative ranking within THIS codebase's coupling pairs (top 50% = hot, next 30% = warm, bottom 20% = cool). NOT an absolute quality grade. 'hot' means this pair co-changes more than most \u2014 it may be intentional and fine."
|
|
691
770
|
};
|
|
692
771
|
function addSharedOptions(cmd) {
|
|
693
772
|
return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
|
|
@@ -724,9 +803,13 @@ addSharedOptions(
|
|
|
724
803
|
exitWithError(err);
|
|
725
804
|
}
|
|
726
805
|
});
|
|
806
|
+
function resolveExcludes(cliExcludes) {
|
|
807
|
+
return [...readIgnoreFile(), ...cliExcludes ?? []];
|
|
808
|
+
}
|
|
727
809
|
function runReport(opts) {
|
|
728
810
|
const top = parseInt(opts.top, 10);
|
|
729
|
-
const
|
|
811
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
812
|
+
const files = runScc(allExcludes);
|
|
730
813
|
const totals = files.reduce(
|
|
731
814
|
(acc, f) => ({
|
|
732
815
|
totalComplexity: acc.totalComplexity + f.complexity,
|
|
@@ -758,12 +841,13 @@ function runReport(opts) {
|
|
|
758
841
|
function runHotspots(opts) {
|
|
759
842
|
const top = parseInt(opts.top, 10);
|
|
760
843
|
const months = parseInt(opts.months, 10);
|
|
761
|
-
const
|
|
844
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
845
|
+
const files = runScc(allExcludes);
|
|
762
846
|
const churn = getChurn(months);
|
|
763
847
|
const defects = getDefects(months);
|
|
764
848
|
const authors = getAuthors(months);
|
|
765
849
|
const nestingDepths = getNestingDepths(files.map((f) => f.file));
|
|
766
|
-
const rankings = computeAllRankings(
|
|
850
|
+
const { rankings, skipped } = computeAllRankings(
|
|
767
851
|
files,
|
|
768
852
|
churn,
|
|
769
853
|
defects,
|
|
@@ -777,6 +861,7 @@ function runHotspots(opts) {
|
|
|
777
861
|
guide: HOTSPOTS_GUIDE,
|
|
778
862
|
churnWindow: `${months} months`,
|
|
779
863
|
rankings,
|
|
864
|
+
skipped: Object.keys(skipped).length > 0 ? skipped : void 0,
|
|
780
865
|
composite
|
|
781
866
|
};
|
|
782
867
|
if (opts.format === "table") {
|
|
@@ -796,9 +881,10 @@ function runCoupling(opts) {
|
|
|
796
881
|
const top = parseInt(opts.top, 10);
|
|
797
882
|
const months = parseInt(opts.months, 10);
|
|
798
883
|
const minCochanges = parseInt(opts.minCochanges, 10);
|
|
799
|
-
const
|
|
884
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
885
|
+
const files = runScc(allExcludes);
|
|
800
886
|
const churn = getChurn(months);
|
|
801
|
-
const cochanges = getCoChanges(months,
|
|
887
|
+
const cochanges = getCoChanges(months, allExcludes);
|
|
802
888
|
const complexityMap = /* @__PURE__ */ new Map();
|
|
803
889
|
for (const f of files) {
|
|
804
890
|
complexityMap.set(f.file, f.complexity);
|
|
@@ -810,7 +896,7 @@ function runCoupling(opts) {
|
|
|
810
896
|
minCochanges
|
|
811
897
|
);
|
|
812
898
|
const limited = top > 0 ? couplings.slice(0, top) : couplings;
|
|
813
|
-
const tierCounts = {
|
|
899
|
+
const tierCounts = { hot: 0, warm: 0, cool: 0 };
|
|
814
900
|
for (const c of couplings) {
|
|
815
901
|
tierCounts[c.tier]++;
|
|
816
902
|
}
|