@wbern/obscene 1.0.1 → 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 +143 -25
  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);
@@ -344,6 +365,65 @@ function getNestingDepths(filePaths) {
344
365
  }
345
366
  return depths;
346
367
  }
368
+ var RRF_K = 10;
369
+ function computeComposite(rankings, churn, top) {
370
+ const totalDimensions = Object.keys(rankings).length;
371
+ const fileScores = /* @__PURE__ */ new Map();
372
+ for (const ranking of Object.values(rankings)) {
373
+ for (let i = 0; i < ranking.entries.length; i++) {
374
+ const file = ranking.entries[i].file;
375
+ const rrf = 1 / (RRF_K + i + 1);
376
+ const existing = fileScores.get(file);
377
+ if (existing) {
378
+ existing.score += rrf;
379
+ existing.dims += 1;
380
+ } else {
381
+ fileScores.set(file, { score: rrf, dims: 1 });
382
+ }
383
+ }
384
+ }
385
+ const entries = [];
386
+ for (const [file, data] of fileScores) {
387
+ entries.push({
388
+ file,
389
+ score: Math.round(data.score * 1e4) / 1e4,
390
+ percentOfTotal: 0,
391
+ tier: "cool",
392
+ churn: churn.get(file) ?? 0,
393
+ dimensionCount: data.dims
394
+ });
395
+ }
396
+ entries.sort((a, b) => b.score - a.score);
397
+ const totalScore = entries.reduce((sum, e) => sum + e.score, 0);
398
+ if (totalScore === 0) {
399
+ return {
400
+ label: "Combined",
401
+ scoreFormula: "reciprocal rank fusion across all dimensions",
402
+ totalScore: 0,
403
+ tierCounts: { hot: 0, warm: 0, cool: 0 },
404
+ totalDimensions,
405
+ totalEntries: 0,
406
+ showing: 0,
407
+ entries: []
408
+ };
409
+ }
410
+ assignTiers(entries, totalScore);
411
+ const limited = top > 0 ? entries.slice(0, top) : entries;
412
+ const tierCounts = { hot: 0, warm: 0, cool: 0 };
413
+ for (const e of entries) {
414
+ tierCounts[e.tier]++;
415
+ }
416
+ return {
417
+ label: "Combined",
418
+ scoreFormula: "reciprocal rank fusion across all dimensions",
419
+ totalScore: Math.round(totalScore * 1e4) / 1e4,
420
+ tierCounts,
421
+ totalDimensions,
422
+ totalEntries: entries.length,
423
+ showing: limited.length,
424
+ entries: limited
425
+ };
426
+ }
347
427
 
348
428
  // src/color.ts
349
429
  import pc from "picocolors";
@@ -384,19 +464,19 @@ function truncate(s, max) {
384
464
  return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
385
465
  }
386
466
  function tierLabel(tier) {
387
- if (tier === "danger") return pc.red("\u{1F534} DANGER");
388
- if (tier === "watch") return pc.yellow("\u{1F7E1} WATCH");
389
- 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");
390
470
  }
391
471
  function colorRow(tier, text) {
392
- if (tier === "danger") return pc.red(text);
393
- if (tier === "watch") return pc.yellow(text);
472
+ if (tier === "hot") return pc.red(text);
473
+ if (tier === "warm") return pc.yellow(text);
394
474
  return pc.green(text);
395
475
  }
396
476
  function tierSummary(tierCounts, showing, total) {
397
477
  const lines = [];
398
478
  lines.push(
399
- `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`)}`
400
480
  );
401
481
  lines.push(`Showing: ${showing} of ${total}`);
402
482
  return lines;
@@ -552,6 +632,16 @@ function formatHotspotsTable(output) {
552
632
  lines.push("");
553
633
  }
554
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
+ }
555
645
  lines.push("");
556
646
  lines.push(
557
647
  "Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
@@ -590,10 +680,29 @@ function formatCouplingTable(output) {
590
680
  lines.push("Docs: https://github.com/wbern/obscene#metrics");
591
681
  return lines.join("\n");
592
682
  }
683
+ function formatCompositeTable(output) {
684
+ const lines = [];
685
+ lines.push(
686
+ `${output.label} \u2014 Total score: ${output.totalScore.toLocaleString()}`
687
+ );
688
+ lines.push(
689
+ ...tierSummary(output.tierCounts, output.showing, output.totalEntries)
690
+ );
691
+ lines.push("");
692
+ lines.push(
693
+ padRight("File", 50) + padLeft("Score", 9) + padLeft("Churn", 7) + padLeft("Dims", 6) + padLeft("Tier", 12)
694
+ );
695
+ lines.push("\u2500".repeat(84));
696
+ for (const entry of output.entries) {
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);
698
+ lines.push(colorRow(entry.tier, rawRow));
699
+ }
700
+ return lines.join("\n");
701
+ }
593
702
 
594
703
  // src/cli.ts
595
704
  var program = new Command();
596
- program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.0.1");
705
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.2.0");
597
706
  var REPORT_GUIDE = {
598
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.",
599
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.",
@@ -605,13 +714,14 @@ var HOTSPOTS_GUIDE = {
605
714
  nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.",
606
715
  defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may contain latent bugs.",
607
716
  authors: "authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.",
608
- tier: "Relative ranking within THIS codebase (top 50% = danger, next 30% = watch, bottom 20% = stable). NOT an absolute quality grade."
717
+ composite: "Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.",
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."
609
719
  };
610
720
  var COUPLING_GUIDE = {
611
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.",
612
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.",
613
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.",
614
- 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."
615
725
  };
616
726
  function addSharedOptions(cmd) {
617
727
  return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
@@ -687,7 +797,7 @@ function runHotspots(opts) {
687
797
  const defects = getDefects(months);
688
798
  const authors = getAuthors(months);
689
799
  const nestingDepths = getNestingDepths(files.map((f) => f.file));
690
- const rankings = computeAllRankings(
800
+ const { rankings, skipped } = computeAllRankings(
691
801
  files,
692
802
  churn,
693
803
  defects,
@@ -695,15 +805,23 @@ function runHotspots(opts) {
695
805
  authors,
696
806
  top
697
807
  );
808
+ const composite = computeComposite(rankings, churn, top);
698
809
  const output = {
699
810
  generated: (/* @__PURE__ */ new Date()).toISOString(),
700
811
  guide: HOTSPOTS_GUIDE,
701
812
  churnWindow: `${months} months`,
702
- rankings
813
+ rankings,
814
+ skipped: Object.keys(skipped).length > 0 ? skipped : void 0,
815
+ composite
703
816
  };
704
817
  if (opts.format === "table") {
705
818
  process.stdout.write(`${formatHotspotsTable(output)}
706
819
  `);
820
+ if (composite.entries.length > 0) {
821
+ process.stdout.write(`
822
+ ${formatCompositeTable(composite)}
823
+ `);
824
+ }
707
825
  } else {
708
826
  process.stdout.write(`${JSON.stringify(output, null, 2)}
709
827
  `);
@@ -727,7 +845,7 @@ function runCoupling(opts) {
727
845
  minCochanges
728
846
  );
729
847
  const limited = top > 0 ? couplings.slice(0, top) : couplings;
730
- const tierCounts = { danger: 0, watch: 0, stable: 0 };
848
+ const tierCounts = { hot: 0, warm: 0, cool: 0 };
731
849
  for (const c of couplings) {
732
850
  tierCounts[c.tier]++;
733
851
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.0.1",
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": {