@wbern/obscene 1.2.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 +84 -33
- 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\./,
|
|
@@ -113,7 +124,7 @@ function getAuthors(months) {
|
|
|
113
124
|
if (!block.trim()) continue;
|
|
114
125
|
const lines = block.split("\n");
|
|
115
126
|
const author = lines[0].trim();
|
|
116
|
-
if (!author) continue;
|
|
127
|
+
if (!author || author.endsWith("[bot]")) continue;
|
|
117
128
|
for (let i = 1; i < lines.length; i++) {
|
|
118
129
|
const file = normalizePath(lines[i].trim());
|
|
119
130
|
if (!file) continue;
|
|
@@ -425,6 +436,9 @@ function computeComposite(rankings, churn, top) {
|
|
|
425
436
|
};
|
|
426
437
|
}
|
|
427
438
|
|
|
439
|
+
// src/format.ts
|
|
440
|
+
import pc2 from "picocolors";
|
|
441
|
+
|
|
428
442
|
// src/color.ts
|
|
429
443
|
import pc from "picocolors";
|
|
430
444
|
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
@@ -436,7 +450,8 @@ function isWide(cp) {
|
|
|
436
450
|
cp >= 13312 && cp <= 40959 || // Hangul Syllables (U+AC00–U+D7AF)
|
|
437
451
|
cp >= 44032 && cp <= 55215 || // CJK Compatibility Ideographs (U+F900–U+FAFF)
|
|
438
452
|
cp >= 63744 && cp <= 64255 || // Fullwidth Forms (U+FF01–U+FF60, U+FFE0–U+FFE6)
|
|
439
|
-
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)
|
|
440
455
|
cp >= 127744 && cp <= 129791 || // CJK Extension B+ and supplementary ideographs (U+20000–U+2FA1F)
|
|
441
456
|
cp >= 131072 && cp <= 195103
|
|
442
457
|
);
|
|
@@ -446,9 +461,8 @@ function visualWidth(s) {
|
|
|
446
461
|
let width = 0;
|
|
447
462
|
for (const ch of stripped) {
|
|
448
463
|
const cp = ch.codePointAt(0);
|
|
449
|
-
if (cp
|
|
450
|
-
|
|
451
|
-
}
|
|
464
|
+
if (cp === 65038 || cp === 65039) continue;
|
|
465
|
+
width += isWide(cp) ? 2 : 1;
|
|
452
466
|
}
|
|
453
467
|
return width;
|
|
454
468
|
}
|
|
@@ -464,19 +478,19 @@ function truncate(s, max) {
|
|
|
464
478
|
return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
|
|
465
479
|
}
|
|
466
480
|
function tierLabel(tier) {
|
|
467
|
-
if (tier === "hot") return pc.red("\u{1F525} HOT");
|
|
481
|
+
if (tier === "hot") return pc.red("\u{1F525} HOT ");
|
|
468
482
|
if (tier === "warm") return pc.yellow("\u2600\uFE0F WARM");
|
|
469
|
-
return pc.
|
|
483
|
+
return pc.blue("\u{1F9CA} COOL");
|
|
470
484
|
}
|
|
471
485
|
function colorRow(tier, text) {
|
|
472
486
|
if (tier === "hot") return pc.red(text);
|
|
473
487
|
if (tier === "warm") return pc.yellow(text);
|
|
474
|
-
return pc.
|
|
488
|
+
return pc.blue(text);
|
|
475
489
|
}
|
|
476
490
|
function tierSummary(tierCounts, showing, total) {
|
|
477
491
|
const lines = [];
|
|
478
492
|
lines.push(
|
|
479
|
-
`Tiers: ${pc.red(`${tierCounts.hot}
|
|
493
|
+
`Tiers: ${pc.red(`${tierCounts.hot} HOT`)}, ${pc.yellow(`${tierCounts.warm} WARM`)}, ${pc.blue(`${tierCounts.cool} COOL`)}`
|
|
480
494
|
);
|
|
481
495
|
lines.push(`Showing: ${showing} of ${total}`);
|
|
482
496
|
return lines;
|
|
@@ -504,12 +518,16 @@ function formatReportTable(output) {
|
|
|
504
518
|
}
|
|
505
519
|
lines.push("");
|
|
506
520
|
lines.push(
|
|
507
|
-
|
|
521
|
+
pc2.dim(
|
|
522
|
+
"Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
|
|
523
|
+
)
|
|
508
524
|
);
|
|
509
525
|
lines.push(
|
|
510
|
-
|
|
526
|
+
pc2.dim(
|
|
527
|
+
"High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values."
|
|
528
|
+
)
|
|
511
529
|
);
|
|
512
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
530
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
513
531
|
return lines.join("\n");
|
|
514
532
|
}
|
|
515
533
|
function getRankingColumns(key) {
|
|
@@ -593,12 +611,26 @@ function getRankingColumns(key) {
|
|
|
593
611
|
};
|
|
594
612
|
return [...base, ...metricCols[key] ?? [], tierCol];
|
|
595
613
|
}
|
|
596
|
-
|
|
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) {
|
|
597
621
|
const lines = [];
|
|
598
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");
|
|
599
626
|
lines.push(
|
|
600
|
-
`${
|
|
627
|
+
`${prefix}${title} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
|
|
601
628
|
);
|
|
629
|
+
if (description) {
|
|
630
|
+
for (const line of description.split("\n")) {
|
|
631
|
+
lines.push(pc2.dim(line));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
602
634
|
lines.push(
|
|
603
635
|
...tierSummary(ranking.tierCounts, ranking.showing, ranking.totalEntries)
|
|
604
636
|
);
|
|
@@ -627,9 +659,11 @@ function formatHotspotsTable(output) {
|
|
|
627
659
|
const keys = Object.keys(rankings);
|
|
628
660
|
for (let i = 0; i < keys.length; i++) {
|
|
629
661
|
const key = keys[i];
|
|
630
|
-
lines.push(...formatRankingTable(key, rankings[key]));
|
|
662
|
+
lines.push(...formatRankingTable(key, rankings[key], output.guide[key]));
|
|
631
663
|
if (i < keys.length - 1) {
|
|
632
664
|
lines.push("");
|
|
665
|
+
lines.push("\xB7 \xB7 \xB7");
|
|
666
|
+
lines.push("");
|
|
633
667
|
}
|
|
634
668
|
}
|
|
635
669
|
if (output.skipped) {
|
|
@@ -644,12 +678,16 @@ function formatHotspotsTable(output) {
|
|
|
644
678
|
}
|
|
645
679
|
lines.push("");
|
|
646
680
|
lines.push(
|
|
647
|
-
|
|
681
|
+
pc2.dim(
|
|
682
|
+
"Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
|
|
683
|
+
)
|
|
648
684
|
);
|
|
649
685
|
lines.push(
|
|
650
|
-
|
|
686
|
+
pc2.dim(
|
|
687
|
+
"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
|
|
688
|
+
)
|
|
651
689
|
);
|
|
652
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
690
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
653
691
|
return lines.join("\n");
|
|
654
692
|
}
|
|
655
693
|
function formatCouplingTable(output) {
|
|
@@ -669,21 +707,28 @@ function formatCouplingTable(output) {
|
|
|
669
707
|
}
|
|
670
708
|
lines.push("");
|
|
671
709
|
lines.push(
|
|
672
|
-
|
|
710
|
+
pc2.dim(
|
|
711
|
+
"Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files"
|
|
712
|
+
)
|
|
673
713
|
);
|
|
674
714
|
lines.push(
|
|
675
|
-
|
|
715
|
+
pc2.dim(
|
|
716
|
+
"Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine."
|
|
717
|
+
)
|
|
676
718
|
);
|
|
677
719
|
lines.push(
|
|
678
|
-
|
|
720
|
+
pc2.dim(
|
|
721
|
+
"Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown."
|
|
722
|
+
)
|
|
679
723
|
);
|
|
680
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
724
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
681
725
|
return lines.join("\n");
|
|
682
726
|
}
|
|
683
727
|
function formatCompositeTable(output) {
|
|
684
728
|
const lines = [];
|
|
729
|
+
lines.push("\u2550".repeat(84));
|
|
685
730
|
lines.push(
|
|
686
|
-
|
|
731
|
+
`\u2605 ${output.label.toUpperCase()} \u2014 Total score: ${output.totalScore.toLocaleString()}`
|
|
687
732
|
);
|
|
688
733
|
lines.push(
|
|
689
734
|
...tierSummary(output.tierCounts, output.showing, output.totalEntries)
|
|
@@ -702,7 +747,7 @@ function formatCompositeTable(output) {
|
|
|
702
747
|
|
|
703
748
|
// src/cli.ts
|
|
704
749
|
var program = new Command();
|
|
705
|
-
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");
|
|
706
751
|
var REPORT_GUIDE = {
|
|
707
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.",
|
|
708
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.",
|
|
@@ -710,11 +755,11 @@ var REPORT_GUIDE = {
|
|
|
710
755
|
};
|
|
711
756
|
var HOTSPOTS_GUIDE = {
|
|
712
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.",
|
|
713
|
-
complexity: "complexity \xD7 churn.
|
|
714
|
-
nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.",
|
|
715
|
-
defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may
|
|
716
|
-
authors: "authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.",
|
|
717
|
-
composite: "Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.",
|
|
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",
|
|
718
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."
|
|
719
764
|
};
|
|
720
765
|
var COUPLING_GUIDE = {
|
|
@@ -758,9 +803,13 @@ addSharedOptions(
|
|
|
758
803
|
exitWithError(err);
|
|
759
804
|
}
|
|
760
805
|
});
|
|
806
|
+
function resolveExcludes(cliExcludes) {
|
|
807
|
+
return [...readIgnoreFile(), ...cliExcludes ?? []];
|
|
808
|
+
}
|
|
761
809
|
function runReport(opts) {
|
|
762
810
|
const top = parseInt(opts.top, 10);
|
|
763
|
-
const
|
|
811
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
812
|
+
const files = runScc(allExcludes);
|
|
764
813
|
const totals = files.reduce(
|
|
765
814
|
(acc, f) => ({
|
|
766
815
|
totalComplexity: acc.totalComplexity + f.complexity,
|
|
@@ -792,7 +841,8 @@ function runReport(opts) {
|
|
|
792
841
|
function runHotspots(opts) {
|
|
793
842
|
const top = parseInt(opts.top, 10);
|
|
794
843
|
const months = parseInt(opts.months, 10);
|
|
795
|
-
const
|
|
844
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
845
|
+
const files = runScc(allExcludes);
|
|
796
846
|
const churn = getChurn(months);
|
|
797
847
|
const defects = getDefects(months);
|
|
798
848
|
const authors = getAuthors(months);
|
|
@@ -831,9 +881,10 @@ function runCoupling(opts) {
|
|
|
831
881
|
const top = parseInt(opts.top, 10);
|
|
832
882
|
const months = parseInt(opts.months, 10);
|
|
833
883
|
const minCochanges = parseInt(opts.minCochanges, 10);
|
|
834
|
-
const
|
|
884
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
885
|
+
const files = runScc(allExcludes);
|
|
835
886
|
const churn = getChurn(months);
|
|
836
|
-
const cochanges = getCoChanges(months,
|
|
887
|
+
const cochanges = getCoChanges(months, allExcludes);
|
|
837
888
|
const complexityMap = /* @__PURE__ */ new Map();
|
|
838
889
|
for (const f of files) {
|
|
839
890
|
complexityMap.set(f.file, f.complexity);
|