@wbern/obscene 1.2.0 → 1.3.1
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 +89 -34
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,21 @@ 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 (err) {
|
|
16
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
9
24
|
var DEFAULT_EXCLUDES = [
|
|
10
25
|
/\.test\./,
|
|
11
26
|
/\.spec\./,
|
|
@@ -113,7 +128,7 @@ function getAuthors(months) {
|
|
|
113
128
|
if (!block.trim()) continue;
|
|
114
129
|
const lines = block.split("\n");
|
|
115
130
|
const author = lines[0].trim();
|
|
116
|
-
if (!author) continue;
|
|
131
|
+
if (!author || author.endsWith("[bot]")) continue;
|
|
117
132
|
for (let i = 1; i < lines.length; i++) {
|
|
118
133
|
const file = normalizePath(lines[i].trim());
|
|
119
134
|
if (!file) continue;
|
|
@@ -425,6 +440,9 @@ function computeComposite(rankings, churn, top) {
|
|
|
425
440
|
};
|
|
426
441
|
}
|
|
427
442
|
|
|
443
|
+
// src/format.ts
|
|
444
|
+
import pc2 from "picocolors";
|
|
445
|
+
|
|
428
446
|
// src/color.ts
|
|
429
447
|
import pc from "picocolors";
|
|
430
448
|
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
@@ -436,7 +454,8 @@ function isWide(cp) {
|
|
|
436
454
|
cp >= 13312 && cp <= 40959 || // Hangul Syllables (U+AC00–U+D7AF)
|
|
437
455
|
cp >= 44032 && cp <= 55215 || // CJK Compatibility Ideographs (U+F900–U+FAFF)
|
|
438
456
|
cp >= 63744 && cp <= 64255 || // Fullwidth Forms (U+FF01–U+FF60, U+FFE0–U+FFE6)
|
|
439
|
-
cp >= 65281 && cp <= 65376 || cp >= 65504 && cp <= 65510 || //
|
|
457
|
+
cp >= 65281 && cp <= 65376 || cp >= 65504 && cp <= 65510 || // Miscellaneous Symbols (U+2600–U+26FF) — includes ☀, ⚡, etc.
|
|
458
|
+
cp >= 9728 && cp <= 9983 || // Emoji and symbol blocks in supplementary planes (U+1F300–U+1FAFF)
|
|
440
459
|
cp >= 127744 && cp <= 129791 || // CJK Extension B+ and supplementary ideographs (U+20000–U+2FA1F)
|
|
441
460
|
cp >= 131072 && cp <= 195103
|
|
442
461
|
);
|
|
@@ -446,9 +465,8 @@ function visualWidth(s) {
|
|
|
446
465
|
let width = 0;
|
|
447
466
|
for (const ch of stripped) {
|
|
448
467
|
const cp = ch.codePointAt(0);
|
|
449
|
-
if (cp
|
|
450
|
-
|
|
451
|
-
}
|
|
468
|
+
if (cp === 65038 || cp === 65039) continue;
|
|
469
|
+
width += isWide(cp) ? 2 : 1;
|
|
452
470
|
}
|
|
453
471
|
return width;
|
|
454
472
|
}
|
|
@@ -464,19 +482,19 @@ function truncate(s, max) {
|
|
|
464
482
|
return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
|
|
465
483
|
}
|
|
466
484
|
function tierLabel(tier) {
|
|
467
|
-
if (tier === "hot") return pc.red("\u{1F525} HOT");
|
|
485
|
+
if (tier === "hot") return pc.red("\u{1F525} HOT ");
|
|
468
486
|
if (tier === "warm") return pc.yellow("\u2600\uFE0F WARM");
|
|
469
|
-
return pc.
|
|
487
|
+
return pc.blue("\u{1F9CA} COOL");
|
|
470
488
|
}
|
|
471
489
|
function colorRow(tier, text) {
|
|
472
490
|
if (tier === "hot") return pc.red(text);
|
|
473
491
|
if (tier === "warm") return pc.yellow(text);
|
|
474
|
-
return pc.
|
|
492
|
+
return pc.blue(text);
|
|
475
493
|
}
|
|
476
494
|
function tierSummary(tierCounts, showing, total) {
|
|
477
495
|
const lines = [];
|
|
478
496
|
lines.push(
|
|
479
|
-
`Tiers: ${pc.red(`${tierCounts.hot}
|
|
497
|
+
`Tiers: ${pc.red(`${tierCounts.hot} HOT`)}, ${pc.yellow(`${tierCounts.warm} WARM`)}, ${pc.blue(`${tierCounts.cool} COOL`)}`
|
|
480
498
|
);
|
|
481
499
|
lines.push(`Showing: ${showing} of ${total}`);
|
|
482
500
|
return lines;
|
|
@@ -504,12 +522,16 @@ function formatReportTable(output) {
|
|
|
504
522
|
}
|
|
505
523
|
lines.push("");
|
|
506
524
|
lines.push(
|
|
507
|
-
|
|
525
|
+
pc2.dim(
|
|
526
|
+
"Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
|
|
527
|
+
)
|
|
508
528
|
);
|
|
509
529
|
lines.push(
|
|
510
|
-
|
|
530
|
+
pc2.dim(
|
|
531
|
+
"High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values."
|
|
532
|
+
)
|
|
511
533
|
);
|
|
512
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
534
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
513
535
|
return lines.join("\n");
|
|
514
536
|
}
|
|
515
537
|
function getRankingColumns(key) {
|
|
@@ -593,12 +615,26 @@ function getRankingColumns(key) {
|
|
|
593
615
|
};
|
|
594
616
|
return [...base, ...metricCols[key] ?? [], tierCol];
|
|
595
617
|
}
|
|
596
|
-
|
|
618
|
+
var METRIC_EMOJI = {
|
|
619
|
+
complexity: "\u{1F9EC}",
|
|
620
|
+
nesting: "\u{1F4CF}",
|
|
621
|
+
defects: "\u{1F41B}",
|
|
622
|
+
authors: "\u{1F465}"
|
|
623
|
+
};
|
|
624
|
+
function formatRankingTable(key, ranking, description) {
|
|
597
625
|
const lines = [];
|
|
598
626
|
const cols = getRankingColumns(key);
|
|
627
|
+
const emoji = METRIC_EMOJI[key];
|
|
628
|
+
const prefix = emoji ? `${emoji} ` : "";
|
|
629
|
+
const title = ranking.label.toUpperCase().replace("CHURN", "\u{1F504} CHURN");
|
|
599
630
|
lines.push(
|
|
600
|
-
`${
|
|
631
|
+
`${prefix}${title} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
|
|
601
632
|
);
|
|
633
|
+
if (description) {
|
|
634
|
+
for (const line of description.split("\n")) {
|
|
635
|
+
lines.push(pc2.dim(line));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
602
638
|
lines.push(
|
|
603
639
|
...tierSummary(ranking.tierCounts, ranking.showing, ranking.totalEntries)
|
|
604
640
|
);
|
|
@@ -627,9 +663,11 @@ function formatHotspotsTable(output) {
|
|
|
627
663
|
const keys = Object.keys(rankings);
|
|
628
664
|
for (let i = 0; i < keys.length; i++) {
|
|
629
665
|
const key = keys[i];
|
|
630
|
-
lines.push(...formatRankingTable(key, rankings[key]));
|
|
666
|
+
lines.push(...formatRankingTable(key, rankings[key], output.guide[key]));
|
|
631
667
|
if (i < keys.length - 1) {
|
|
632
668
|
lines.push("");
|
|
669
|
+
lines.push("\xB7 \xB7 \xB7");
|
|
670
|
+
lines.push("");
|
|
633
671
|
}
|
|
634
672
|
}
|
|
635
673
|
if (output.skipped) {
|
|
@@ -644,12 +682,16 @@ function formatHotspotsTable(output) {
|
|
|
644
682
|
}
|
|
645
683
|
lines.push("");
|
|
646
684
|
lines.push(
|
|
647
|
-
|
|
685
|
+
pc2.dim(
|
|
686
|
+
"Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
|
|
687
|
+
)
|
|
648
688
|
);
|
|
649
689
|
lines.push(
|
|
650
|
-
|
|
690
|
+
pc2.dim(
|
|
691
|
+
"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
|
|
692
|
+
)
|
|
651
693
|
);
|
|
652
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
694
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
653
695
|
return lines.join("\n");
|
|
654
696
|
}
|
|
655
697
|
function formatCouplingTable(output) {
|
|
@@ -669,21 +711,28 @@ function formatCouplingTable(output) {
|
|
|
669
711
|
}
|
|
670
712
|
lines.push("");
|
|
671
713
|
lines.push(
|
|
672
|
-
|
|
714
|
+
pc2.dim(
|
|
715
|
+
"Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files"
|
|
716
|
+
)
|
|
673
717
|
);
|
|
674
718
|
lines.push(
|
|
675
|
-
|
|
719
|
+
pc2.dim(
|
|
720
|
+
"Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine."
|
|
721
|
+
)
|
|
676
722
|
);
|
|
677
723
|
lines.push(
|
|
678
|
-
|
|
724
|
+
pc2.dim(
|
|
725
|
+
"Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown."
|
|
726
|
+
)
|
|
679
727
|
);
|
|
680
|
-
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
728
|
+
lines.push(pc2.dim("Docs: https://github.com/wbern/obscene#metrics"));
|
|
681
729
|
return lines.join("\n");
|
|
682
730
|
}
|
|
683
731
|
function formatCompositeTable(output) {
|
|
684
732
|
const lines = [];
|
|
733
|
+
lines.push("\u2550".repeat(84));
|
|
685
734
|
lines.push(
|
|
686
|
-
|
|
735
|
+
`\u2605 ${output.label.toUpperCase()} \u2014 Total score: ${output.totalScore.toLocaleString()}`
|
|
687
736
|
);
|
|
688
737
|
lines.push(
|
|
689
738
|
...tierSummary(output.tierCounts, output.showing, output.totalEntries)
|
|
@@ -702,7 +751,7 @@ function formatCompositeTable(output) {
|
|
|
702
751
|
|
|
703
752
|
// src/cli.ts
|
|
704
753
|
var program = new Command();
|
|
705
|
-
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.
|
|
754
|
+
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.3.1");
|
|
706
755
|
var REPORT_GUIDE = {
|
|
707
756
|
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
757
|
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 +759,11 @@ var REPORT_GUIDE = {
|
|
|
710
759
|
};
|
|
711
760
|
var HOTSPOTS_GUIDE = {
|
|
712
761
|
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.",
|
|
762
|
+
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",
|
|
763
|
+
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",
|
|
764
|
+
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",
|
|
765
|
+
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",
|
|
766
|
+
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
767
|
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
768
|
};
|
|
720
769
|
var COUPLING_GUIDE = {
|
|
@@ -726,7 +775,7 @@ var COUPLING_GUIDE = {
|
|
|
726
775
|
function addSharedOptions(cmd) {
|
|
727
776
|
return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
|
|
728
777
|
"--exclude <patterns...>",
|
|
729
|
-
"additional file patterns to exclude (
|
|
778
|
+
"additional file patterns to exclude (also reads .obsignore / .obsceneignore)"
|
|
730
779
|
);
|
|
731
780
|
}
|
|
732
781
|
addSharedOptions(
|
|
@@ -758,9 +807,13 @@ addSharedOptions(
|
|
|
758
807
|
exitWithError(err);
|
|
759
808
|
}
|
|
760
809
|
});
|
|
810
|
+
function resolveExcludes(cliExcludes) {
|
|
811
|
+
return [...readIgnoreFile(), ...cliExcludes ?? []];
|
|
812
|
+
}
|
|
761
813
|
function runReport(opts) {
|
|
762
814
|
const top = parseInt(opts.top, 10);
|
|
763
|
-
const
|
|
815
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
816
|
+
const files = runScc(allExcludes);
|
|
764
817
|
const totals = files.reduce(
|
|
765
818
|
(acc, f) => ({
|
|
766
819
|
totalComplexity: acc.totalComplexity + f.complexity,
|
|
@@ -792,7 +845,8 @@ function runReport(opts) {
|
|
|
792
845
|
function runHotspots(opts) {
|
|
793
846
|
const top = parseInt(opts.top, 10);
|
|
794
847
|
const months = parseInt(opts.months, 10);
|
|
795
|
-
const
|
|
848
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
849
|
+
const files = runScc(allExcludes);
|
|
796
850
|
const churn = getChurn(months);
|
|
797
851
|
const defects = getDefects(months);
|
|
798
852
|
const authors = getAuthors(months);
|
|
@@ -831,9 +885,10 @@ function runCoupling(opts) {
|
|
|
831
885
|
const top = parseInt(opts.top, 10);
|
|
832
886
|
const months = parseInt(opts.months, 10);
|
|
833
887
|
const minCochanges = parseInt(opts.minCochanges, 10);
|
|
834
|
-
const
|
|
888
|
+
const allExcludes = resolveExcludes(opts.exclude);
|
|
889
|
+
const files = runScc(allExcludes);
|
|
835
890
|
const churn = getChurn(months);
|
|
836
|
-
const cochanges = getCoChanges(months,
|
|
891
|
+
const cochanges = getCoChanges(months, allExcludes);
|
|
837
892
|
const complexityMap = /* @__PURE__ */ new Map();
|
|
838
893
|
for (const f of files) {
|
|
839
894
|
complexityMap.set(f.file, f.complexity);
|