@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.
Files changed (2) hide show
  1. package/dist/cli.js +143 -57
  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\./,
@@ -18,8 +29,10 @@ var DEFAULT_EXCLUDES = [
18
29
  /\.stories\./,
19
30
  /\.d\.ts$/
20
31
  ];
21
- var DANGER_CUMULATIVE = 0.5;
22
- var WATCH_CUMULATIVE = 0.8;
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 <= DANGER_CUMULATIVE) {
178
- item.tier = "danger";
179
- } else if (cumulativeShare <= WATCH_CUMULATIVE) {
180
- item.tier = "watch";
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 = "stable";
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: "stable",
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
- danger: 0,
255
- watch: 0,
256
- stable: 0
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: "stable"
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: "stable",
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: { danger: 0, watch: 0, stable: 0 },
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 = { danger: 0, watch: 0, stable: 0 };
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 || // 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)
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 !== void 0) {
426
- width += isWide(cp) ? 2 : 1;
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 === "danger") return pc.red("\u{1F534} DANGER");
444
- if (tier === "watch") return pc.yellow("\u{1F7E1} WATCH");
445
- return pc.green("\u{1F7E2} stable");
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 === "danger") return pc.red(text);
449
- if (tier === "watch") return pc.yellow(text);
450
- return pc.green(text);
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.danger} danger`)}, ${pc.yellow(`${tierCounts.watch} watch`)}, ${pc.green(`${tierCounts.stable} stable`)}`
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
- "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
+ )
484
524
  );
485
525
  lines.push(
486
- "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
+ )
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
- 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) {
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
- `${ranking.label} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
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
- "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
+ )
614
684
  );
615
685
  lines.push(
616
- "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
+ )
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
- "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
+ )
639
713
  );
640
714
  lines.push(
641
- "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
+ )
642
718
  );
643
719
  lines.push(
644
- "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
+ )
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
- `${output.label} \u2014 Total score: ${output.totalScore.toLocaleString()}`
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}/4`, 6) + padLeft(tierLabel(entry.tier), 12);
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.1.0");
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. Ranks files by combined risk: complex code that changes often.",
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 contain latent bugs.",
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% = danger, next 30% = watch, bottom 20% = stable). NOT an absolute quality grade."
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% = danger, next 30% = watch, bottom 20% = stable). NOT an absolute quality grade. 'danger' means this pair co-changes more than most \u2014 it may be intentional and fine."
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
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 files = runScc(opts.exclude);
884
+ const allExcludes = resolveExcludes(opts.exclude);
885
+ const files = runScc(allExcludes);
800
886
  const churn = getChurn(months);
801
- const cochanges = getCoChanges(months, opts.exclude);
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 = { danger: 0, watch: 0, stable: 0 };
899
+ const tierCounts = { hot: 0, warm: 0, cool: 0 };
814
900
  for (const c of couplings) {
815
901
  tierCounts[c.tier]++;
816
902
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.1.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": {