@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.
- package/dist/cli.js +143 -25
- 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
|
|
22
|
-
var
|
|
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 <=
|
|
178
|
-
item.tier = "
|
|
179
|
-
} else if (cumulativeShare <=
|
|
180
|
-
item.tier = "
|
|
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 = "
|
|
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: "
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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: "
|
|
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 === "
|
|
388
|
-
if (tier === "
|
|
389
|
-
return pc.green("\u{
|
|
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 === "
|
|
393
|
-
if (tier === "
|
|
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.
|
|
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
|
|
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
|
-
|
|
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% =
|
|
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 = {
|
|
848
|
+
const tierCounts = { hot: 0, warm: 0, cool: 0 };
|
|
731
849
|
for (const c of couplings) {
|
|
732
850
|
tierCounts[c.tier]++;
|
|
733
851
|
}
|