@wbern/obscene 1.0.0 → 1.1.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 +107 -3
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -344,12 +344,89 @@ function getNestingDepths(filePaths) {
344
344
  }
345
345
  return depths;
346
346
  }
347
+ var RRF_K = 10;
348
+ function computeComposite(rankings, churn, top) {
349
+ const fileScores = /* @__PURE__ */ new Map();
350
+ for (const ranking of Object.values(rankings)) {
351
+ for (let i = 0; i < ranking.entries.length; i++) {
352
+ const file = ranking.entries[i].file;
353
+ const rrf = 1 / (RRF_K + i + 1);
354
+ const existing = fileScores.get(file);
355
+ if (existing) {
356
+ existing.score += rrf;
357
+ existing.dims += 1;
358
+ } else {
359
+ fileScores.set(file, { score: rrf, dims: 1 });
360
+ }
361
+ }
362
+ }
363
+ const entries = [];
364
+ for (const [file, data] of fileScores) {
365
+ entries.push({
366
+ file,
367
+ score: Math.round(data.score * 1e4) / 1e4,
368
+ percentOfTotal: 0,
369
+ tier: "stable",
370
+ churn: churn.get(file) ?? 0,
371
+ dimensionCount: data.dims
372
+ });
373
+ }
374
+ entries.sort((a, b) => b.score - a.score);
375
+ const totalScore = entries.reduce((sum, e) => sum + e.score, 0);
376
+ if (totalScore === 0) {
377
+ return {
378
+ label: "Combined",
379
+ scoreFormula: "reciprocal rank fusion across all dimensions",
380
+ totalScore: 0,
381
+ tierCounts: { danger: 0, watch: 0, stable: 0 },
382
+ totalEntries: 0,
383
+ showing: 0,
384
+ entries: []
385
+ };
386
+ }
387
+ assignTiers(entries, totalScore);
388
+ const limited = top > 0 ? entries.slice(0, top) : entries;
389
+ const tierCounts = { danger: 0, watch: 0, stable: 0 };
390
+ for (const e of entries) {
391
+ tierCounts[e.tier]++;
392
+ }
393
+ return {
394
+ label: "Combined",
395
+ scoreFormula: "reciprocal rank fusion across all dimensions",
396
+ totalScore: Math.round(totalScore * 1e4) / 1e4,
397
+ tierCounts,
398
+ totalEntries: entries.length,
399
+ showing: limited.length,
400
+ entries: limited
401
+ };
402
+ }
347
403
 
348
404
  // src/color.ts
349
405
  import pc from "picocolors";
350
406
  var ANSI_RE = /\x1b\[[0-9;]*m/g;
407
+ function isWide(cp) {
408
+ return (
409
+ // CJK Radicals through Katakana (U+2E80–U+30FF) + CJK Symbols (U+3000–U+303F)
410
+ cp >= 11904 && cp <= 12543 || // Enclosed CJK Letters + CJK Compatibility (U+3200–U+33FF)
411
+ cp >= 12800 && cp <= 13311 || // CJK Extension A (U+3400–U+4DBF) + CJK Unified Ideographs (U+4E00–U+9FFF)
412
+ cp >= 13312 && cp <= 40959 || // Hangul Syllables (U+AC00–U+D7AF)
413
+ cp >= 44032 && cp <= 55215 || // CJK Compatibility Ideographs (U+F900–U+FAFF)
414
+ 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)
416
+ cp >= 127744 && cp <= 129791 || // CJK Extension B+ and supplementary ideographs (U+20000–U+2FA1F)
417
+ cp >= 131072 && cp <= 195103
418
+ );
419
+ }
351
420
  function visualWidth(s) {
352
- return s.replace(ANSI_RE, "").length;
421
+ const stripped = s.replace(ANSI_RE, "");
422
+ let width = 0;
423
+ for (const ch of stripped) {
424
+ const cp = ch.codePointAt(0);
425
+ if (cp !== void 0) {
426
+ width += isWide(cp) ? 2 : 1;
427
+ }
428
+ }
429
+ return width;
353
430
  }
354
431
  function padRight(s, n) {
355
432
  const w = visualWidth(s);
@@ -569,10 +646,29 @@ function formatCouplingTable(output) {
569
646
  lines.push("Docs: https://github.com/wbern/obscene#metrics");
570
647
  return lines.join("\n");
571
648
  }
649
+ function formatCompositeTable(output) {
650
+ const lines = [];
651
+ lines.push(
652
+ `${output.label} \u2014 Total score: ${output.totalScore.toLocaleString()}`
653
+ );
654
+ lines.push(
655
+ ...tierSummary(output.tierCounts, output.showing, output.totalEntries)
656
+ );
657
+ lines.push("");
658
+ lines.push(
659
+ padRight("File", 50) + padLeft("Score", 9) + padLeft("Churn", 7) + padLeft("Dims", 6) + padLeft("Tier", 12)
660
+ );
661
+ lines.push("\u2500".repeat(84));
662
+ 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);
664
+ lines.push(colorRow(entry.tier, rawRow));
665
+ }
666
+ return lines.join("\n");
667
+ }
572
668
 
573
669
  // src/cli.ts
574
670
  var program = new Command();
575
- program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.0.0");
671
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.1.0");
576
672
  var REPORT_GUIDE = {
577
673
  complexity: "Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",
578
674
  complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
@@ -584,6 +680,7 @@ var HOTSPOTS_GUIDE = {
584
680
  nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.",
585
681
  defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may contain latent bugs.",
586
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.",
587
684
  tier: "Relative ranking within THIS codebase (top 50% = danger, next 30% = watch, bottom 20% = stable). NOT an absolute quality grade."
588
685
  };
589
686
  var COUPLING_GUIDE = {
@@ -674,15 +771,22 @@ function runHotspots(opts) {
674
771
  authors,
675
772
  top
676
773
  );
774
+ const composite = computeComposite(rankings, churn, top);
677
775
  const output = {
678
776
  generated: (/* @__PURE__ */ new Date()).toISOString(),
679
777
  guide: HOTSPOTS_GUIDE,
680
778
  churnWindow: `${months} months`,
681
- rankings
779
+ rankings,
780
+ composite
682
781
  };
683
782
  if (opts.format === "table") {
684
783
  process.stdout.write(`${formatHotspotsTable(output)}
685
784
  `);
785
+ if (composite.entries.length > 0) {
786
+ process.stdout.write(`
787
+ ${formatCompositeTable(composite)}
788
+ `);
789
+ }
686
790
  } else {
687
791
  process.stdout.write(`${JSON.stringify(output, null, 2)}
688
792
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": {