@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.
Files changed (2) hide show
  1. package/dist/cli.js +84 -33
  2. 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 || // Emoji and symbol blocks in supplementary planes (U+1F300–U+1FAFF)
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 !== void 0) {
450
- width += isWide(cp) ? 2 : 1;
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.green("\u{1F9CA} cool");
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.green(text);
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} hot`)}, ${pc.yellow(`${tierCounts.warm} warm`)}, ${pc.green(`${tierCounts.cool} cool`)}`
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
- "Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
521
+ pc2.dim(
522
+ "Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines"
523
+ )
508
524
  );
509
525
  lines.push(
510
- "High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values."
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
- function formatRankingTable(key, ranking) {
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
- `${ranking.label} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
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
- "Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
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
- "High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
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
- "Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files"
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
- "Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine."
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
- "Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown."
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
- `${output.label} \u2014 Total score: ${output.totalScore.toLocaleString()}`
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.2.0");
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. 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.",
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
884
+ const allExcludes = resolveExcludes(opts.exclude);
885
+ const files = runScc(allExcludes);
835
886
  const churn = getChurn(months);
836
- const cochanges = getCoChanges(months, opts.exclude);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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": {