@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.
Files changed (2) hide show
  1. package/dist/cli.js +89 -34
  2. 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 || // Emoji and symbol blocks in supplementary planes (U+1F300–U+1FAFF)
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 !== void 0) {
450
- width += isWide(cp) ? 2 : 1;
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.green("\u{1F9CA} cool");
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.green(text);
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} hot`)}, ${pc.yellow(`${tierCounts.warm} warm`)}, ${pc.green(`${tierCounts.cool} cool`)}`
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
- "Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
525
+ pc2.dim(
526
+ "Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
527
+ )
508
528
  );
509
529
  lines.push(
510
- "High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values."
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
- function formatRankingTable(key, ranking) {
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
- `${ranking.label} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
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
- "Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
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
- "High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
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
- "Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files"
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
- "Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine."
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
- "Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown."
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
- `${output.label} \u2014 Total score: ${output.totalScore.toLocaleString()}`
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.2.0");
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. Ranks files by combined risk: complex code that changes often.",
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 contain latent bugs.",
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 (e.g. *.generated.*)"
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
888
+ const allExcludes = resolveExcludes(opts.exclude);
889
+ const files = runScc(allExcludes);
835
890
  const churn = getChurn(months);
836
- const cochanges = getCoChanges(months, opts.exclude);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Identify hotspot files — complex code that changes frequently. Churn × complexity analysis for any git repo.",
5
5
  "type": "module",
6
6
  "bin": {