@wbern/obscene 1.1.0 → 1.2.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 +63 -28
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -18,8 +18,10 @@ var DEFAULT_EXCLUDES = [
18
18
  /\.stories\./,
19
19
  /\.d\.ts$/
20
20
  ];
21
- var DANGER_CUMULATIVE = 0.5;
22
- var WATCH_CUMULATIVE = 0.8;
21
+ var HOT_CUMULATIVE = 0.5;
22
+ var WARM_CUMULATIVE = 0.8;
23
+ var MIN_FIX_COMMITS = 5;
24
+ var MIN_FILES_WITH_FIXES = 3;
23
25
  function isExcluded(location, patterns) {
24
26
  return patterns.some((p) => p.test(location));
25
27
  }
@@ -174,12 +176,12 @@ function assignTiers(items, totalScore) {
174
176
  item.percentOfTotal = Math.round(item.score / totalScore * 1e3) / 10;
175
177
  cumulative += item.score;
176
178
  const cumulativeShare = cumulative / totalScore;
177
- if (cumulativeShare <= DANGER_CUMULATIVE) {
178
- item.tier = "danger";
179
- } else if (cumulativeShare <= WATCH_CUMULATIVE) {
180
- item.tier = "watch";
179
+ if (cumulativeShare <= HOT_CUMULATIVE) {
180
+ item.tier = "hot";
181
+ } else if (cumulativeShare <= WARM_CUMULATIVE) {
182
+ item.tier = "warm";
181
183
  } else {
182
- item.tier = "stable";
184
+ item.tier = "cool";
183
185
  }
184
186
  }
185
187
  }
@@ -213,7 +215,7 @@ function computeRanking(files, churn, metricExtractor, densityExtractor) {
213
215
  file: f.file,
214
216
  score: metricValue * fileChurn,
215
217
  percentOfTotal: 0,
216
- tier: "stable",
218
+ tier: "cool",
217
219
  churn: fileChurn,
218
220
  metricValue,
219
221
  metricDensity: densityExtractor ? densityExtractor(f) : void 0
@@ -244,16 +246,35 @@ function computeAllRankings(files, churn, defects, nestingDepths, authors, top)
244
246
  extract: (f) => authors.get(f.file) ?? 0
245
247
  }
246
248
  };
249
+ const skipped = {};
250
+ const totalFixCommits = [...defects.values()].reduce((s, v) => s + v, 0);
251
+ const filesWithFixes = defects.size;
252
+ if (totalFixCommits < MIN_FIX_COMMITS || filesWithFixes < MIN_FILES_WITH_FIXES) {
253
+ skipped.defects = {
254
+ reason: `insufficient data (${totalFixCommits} fix: commits across ${filesWithFixes} files, need ${MIN_FIX_COMMITS}+ commits across ${MIN_FILES_WITH_FIXES}+ files)`,
255
+ suggestion: "Adopt conventional commits with fix: prefix. See conventionalcommits.org"
256
+ };
257
+ }
258
+ let maxAuthors = 0;
259
+ for (const count of authors.values()) {
260
+ if (count > maxAuthors) maxAuthors = count;
261
+ }
262
+ if (maxAuthors <= 1) {
263
+ skipped.authors = {
264
+ reason: "all files have the same author count \u2014 no variance to rank"
265
+ };
266
+ }
247
267
  const rankings = {};
248
268
  for (const def of RANKING_DEFS) {
269
+ if (skipped[def.key]) continue;
249
270
  const ext = extractors[def.key];
250
271
  const allEntries = computeRanking(files, churn, ext.extract, ext.density);
251
272
  if (allEntries.length === 0) continue;
252
273
  const limited = top > 0 ? allEntries.slice(0, top) : allEntries;
253
274
  const tierCounts = {
254
- danger: 0,
255
- watch: 0,
256
- stable: 0
275
+ hot: 0,
276
+ warm: 0,
277
+ cool: 0
257
278
  };
258
279
  for (const e of allEntries) {
259
280
  tierCounts[e.tier]++;
@@ -268,7 +289,7 @@ function computeAllRankings(files, churn, defects, nestingDepths, authors, top)
268
289
  entries: limited
269
290
  };
270
291
  }
271
- return rankings;
292
+ return { rankings, skipped };
272
293
  }
273
294
  function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
274
295
  const entries = [];
@@ -286,7 +307,7 @@ function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
286
307
  totalComplexity,
287
308
  couplingScore: count,
288
309
  percentOfTotal: 0,
289
- tier: "stable"
310
+ tier: "cool"
290
311
  });
291
312
  }
292
313
  entries.sort((a, b) => b.couplingScore - a.couplingScore);
@@ -346,6 +367,7 @@ function getNestingDepths(filePaths) {
346
367
  }
347
368
  var RRF_K = 10;
348
369
  function computeComposite(rankings, churn, top) {
370
+ const totalDimensions = Object.keys(rankings).length;
349
371
  const fileScores = /* @__PURE__ */ new Map();
350
372
  for (const ranking of Object.values(rankings)) {
351
373
  for (let i = 0; i < ranking.entries.length; i++) {
@@ -366,7 +388,7 @@ function computeComposite(rankings, churn, top) {
366
388
  file,
367
389
  score: Math.round(data.score * 1e4) / 1e4,
368
390
  percentOfTotal: 0,
369
- tier: "stable",
391
+ tier: "cool",
370
392
  churn: churn.get(file) ?? 0,
371
393
  dimensionCount: data.dims
372
394
  });
@@ -378,7 +400,8 @@ function computeComposite(rankings, churn, top) {
378
400
  label: "Combined",
379
401
  scoreFormula: "reciprocal rank fusion across all dimensions",
380
402
  totalScore: 0,
381
- tierCounts: { danger: 0, watch: 0, stable: 0 },
403
+ tierCounts: { hot: 0, warm: 0, cool: 0 },
404
+ totalDimensions,
382
405
  totalEntries: 0,
383
406
  showing: 0,
384
407
  entries: []
@@ -386,7 +409,7 @@ function computeComposite(rankings, churn, top) {
386
409
  }
387
410
  assignTiers(entries, totalScore);
388
411
  const limited = top > 0 ? entries.slice(0, top) : entries;
389
- const tierCounts = { danger: 0, watch: 0, stable: 0 };
412
+ const tierCounts = { hot: 0, warm: 0, cool: 0 };
390
413
  for (const e of entries) {
391
414
  tierCounts[e.tier]++;
392
415
  }
@@ -395,6 +418,7 @@ function computeComposite(rankings, churn, top) {
395
418
  scoreFormula: "reciprocal rank fusion across all dimensions",
396
419
  totalScore: Math.round(totalScore * 1e4) / 1e4,
397
420
  tierCounts,
421
+ totalDimensions,
398
422
  totalEntries: entries.length,
399
423
  showing: limited.length,
400
424
  entries: limited
@@ -440,19 +464,19 @@ function truncate(s, max) {
440
464
  return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
441
465
  }
442
466
  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");
467
+ if (tier === "hot") return pc.red("\u{1F525} HOT");
468
+ if (tier === "warm") return pc.yellow("\u2600\uFE0F WARM");
469
+ return pc.green("\u{1F9CA} cool");
446
470
  }
447
471
  function colorRow(tier, text) {
448
- if (tier === "danger") return pc.red(text);
449
- if (tier === "watch") return pc.yellow(text);
472
+ if (tier === "hot") return pc.red(text);
473
+ if (tier === "warm") return pc.yellow(text);
450
474
  return pc.green(text);
451
475
  }
452
476
  function tierSummary(tierCounts, showing, total) {
453
477
  const lines = [];
454
478
  lines.push(
455
- `Tiers: ${pc.red(`${tierCounts.danger} danger`)}, ${pc.yellow(`${tierCounts.watch} watch`)}, ${pc.green(`${tierCounts.stable} stable`)}`
479
+ `Tiers: ${pc.red(`${tierCounts.hot} hot`)}, ${pc.yellow(`${tierCounts.warm} warm`)}, ${pc.green(`${tierCounts.cool} cool`)}`
456
480
  );
457
481
  lines.push(`Showing: ${showing} of ${total}`);
458
482
  return lines;
@@ -608,6 +632,16 @@ function formatHotspotsTable(output) {
608
632
  lines.push("");
609
633
  }
610
634
  }
635
+ if (output.skipped) {
636
+ for (const [key, info] of Object.entries(output.skipped)) {
637
+ lines.push("");
638
+ const label = key.charAt(0).toUpperCase() + key.slice(1);
639
+ lines.push(`${label} \xD7 Churn \u2014 skipped (${info.reason})`);
640
+ if (info.suggestion) {
641
+ lines.push(` ${info.suggestion}`);
642
+ }
643
+ }
644
+ }
611
645
  lines.push("");
612
646
  lines.push(
613
647
  "Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
@@ -660,7 +694,7 @@ function formatCompositeTable(output) {
660
694
  );
661
695
  lines.push("\u2500".repeat(84));
662
696
  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);
697
+ 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
698
  lines.push(colorRow(entry.tier, rawRow));
665
699
  }
666
700
  return lines.join("\n");
@@ -668,7 +702,7 @@ function formatCompositeTable(output) {
668
702
 
669
703
  // src/cli.ts
670
704
  var program = new Command();
671
- program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.1.0");
705
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.2.0");
672
706
  var REPORT_GUIDE = {
673
707
  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
708
  complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
@@ -681,13 +715,13 @@ var HOTSPOTS_GUIDE = {
681
715
  defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may contain latent bugs.",
682
716
  authors: "authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.",
683
717
  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."
718
+ 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
719
  };
686
720
  var COUPLING_GUIDE = {
687
721
  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
722
  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
723
  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."
724
+ 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
725
  };
692
726
  function addSharedOptions(cmd) {
693
727
  return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
@@ -763,7 +797,7 @@ function runHotspots(opts) {
763
797
  const defects = getDefects(months);
764
798
  const authors = getAuthors(months);
765
799
  const nestingDepths = getNestingDepths(files.map((f) => f.file));
766
- const rankings = computeAllRankings(
800
+ const { rankings, skipped } = computeAllRankings(
767
801
  files,
768
802
  churn,
769
803
  defects,
@@ -777,6 +811,7 @@ function runHotspots(opts) {
777
811
  guide: HOTSPOTS_GUIDE,
778
812
  churnWindow: `${months} months`,
779
813
  rankings,
814
+ skipped: Object.keys(skipped).length > 0 ? skipped : void 0,
780
815
  composite
781
816
  };
782
817
  if (opts.format === "table") {
@@ -810,7 +845,7 @@ function runCoupling(opts) {
810
845
  minCochanges
811
846
  );
812
847
  const limited = top > 0 ? couplings.slice(0, top) : couplings;
813
- const tierCounts = { danger: 0, watch: 0, stable: 0 };
848
+ const tierCounts = { hot: 0, warm: 0, cool: 0 };
814
849
  for (const c of couplings) {
815
850
  tierCounts[c.tier]++;
816
851
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.1.0",
3
+ "version": "1.2.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": {