@wbern/obscene 0.3.1 → 1.0.1
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/README.md +41 -15
- package/dist/cli.js +296 -104
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -60,14 +60,23 @@ obscene report # raw complexity (no churn)
|
|
|
60
60
|
obscene coupling # temporal coupling analysis
|
|
61
61
|
obscene coupling --min-cochanges 1 --format table
|
|
62
62
|
obscene --exclude "*.generated.*"
|
|
63
|
-
obscene | jq '.
|
|
63
|
+
obscene | jq '.rankings.complexity.entries[0]' # pipe-friendly
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
## Commands
|
|
67
67
|
|
|
68
68
|
### `obscene hotspots` (default)
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
Produces **four independent ranking tables**, each scoring files by a different metric multiplied by churn:
|
|
71
|
+
|
|
72
|
+
| Ranking | Score formula | Metric columns |
|
|
73
|
+
|---------|---------------|----------------|
|
|
74
|
+
| Complexity × Churn | `complexity × churn` | Cmplx, Dens |
|
|
75
|
+
| Nesting × Churn | `maxNesting × churn` | Nest |
|
|
76
|
+
| Defects × Churn | `defects × churn` | Dfcts, DfDns |
|
|
77
|
+
| Authors × Churn | `authors × churn` | Auth |
|
|
78
|
+
|
|
79
|
+
Each table has its own tier assignment by cumulative score distribution:
|
|
71
80
|
|
|
72
81
|
| Tier | Range | Meaning |
|
|
73
82
|
|------|-------|---------|
|
|
@@ -75,6 +84,8 @@ Scores each file by `complexity × commits` over a time window, then assigns tie
|
|
|
75
84
|
| **watch** | next 30% (50–80%) | Keep an eye on these |
|
|
76
85
|
| **stable** | bottom 20% | Low risk |
|
|
77
86
|
|
|
87
|
+
A file may rank high in one dimension (e.g. complexity) but low in another (e.g. authors). Tables with no scored entries are omitted.
|
|
88
|
+
|
|
78
89
|
### `obscene coupling`
|
|
79
90
|
|
|
80
91
|
Detects files that frequently change together in the same commit but live in different directories — Tornhill's "temporal coupling" analysis from *Your Code as a Crime Scene* (2015). Surfaces hidden structural dependencies that aren't visible in imports or the module graph.
|
|
@@ -105,9 +116,9 @@ Per-file complexity without churn. Useful for raw complexity distribution.
|
|
|
105
116
|
|
|
106
117
|
### Hotspot metrics
|
|
107
118
|
|
|
108
|
-
####
|
|
119
|
+
#### Score
|
|
109
120
|
|
|
110
|
-
`
|
|
121
|
+
`metric × churn`. Each ranking table uses a different metric (complexity, nesting, defects, or authors) multiplied by churn. See [Why churn × complexity?](#why-churn-x-complexity) for the research backing this approach.
|
|
111
122
|
|
|
112
123
|
#### Churn (`Churn`)
|
|
113
124
|
|
|
@@ -125,9 +136,9 @@ Total cyclomatic complexity as reported by [scc](https://github.com/boyter/scc).
|
|
|
125
136
|
|
|
126
137
|
Count of `fix:` conventional commits touching the file within the churn window. A proxy for historical defect rate — files that attract repeated fixes are more likely to contain latent bugs. Inspired by Moser, Pedrycz & Succi (2008), who showed that change-history metrics outperform static code metrics for defect prediction.
|
|
127
138
|
|
|
128
|
-
#### Defect density (`
|
|
139
|
+
#### Defect density (`DfDns`)
|
|
129
140
|
|
|
130
|
-
`defects / lines of code`.
|
|
141
|
+
`defects / lines of code`. Shown in the Defects × Churn table. Normalizes defect count by file size.
|
|
131
142
|
|
|
132
143
|
#### Nesting depth (`Nest`)
|
|
133
144
|
|
|
@@ -164,19 +175,34 @@ Cumulative score distribution bucket:
|
|
|
164
175
|
## Example output
|
|
165
176
|
|
|
166
177
|
```
|
|
167
|
-
Hotspots — 3 months churn window
|
|
178
|
+
Hotspots — 3 months churn window
|
|
179
|
+
|
|
180
|
+
Complexity × Churn — Total score: 35,452
|
|
168
181
|
Tiers: 3 danger, 13 watch, 194 stable
|
|
169
182
|
Showing: 5 of 210
|
|
170
183
|
|
|
171
|
-
File
|
|
172
|
-
|
|
173
|
-
src/utils/effect-generator.ts
|
|
174
|
-
src/services/game-engine.ts
|
|
175
|
-
src/components/board-renderer.tsx
|
|
176
|
-
src/hooks/use-game-state.ts
|
|
177
|
-
src/utils/move-validator.ts
|
|
184
|
+
File Score % Churn Cmplx Dens Tier
|
|
185
|
+
──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
src/utils/effect-generator.ts 8,296 23.4 68 122 0.12 🔴 DANGER
|
|
187
|
+
src/services/game-engine.ts 4,284 12.1 51 84 0.09 🔴 DANGER
|
|
188
|
+
src/components/board-renderer.tsx 2,940 8.3 42 70 0.11 🔴 DANGER
|
|
189
|
+
src/hooks/use-game-state.ts 1,320 3.7 33 40 0.08 🟡 WATCH
|
|
190
|
+
src/utils/move-validator.ts 945 2.7 27 35 0.06 🟡 WATCH
|
|
191
|
+
|
|
192
|
+
Nesting × Churn — Total score: 1,284
|
|
193
|
+
Tiers: 2 danger, 5 watch, 203 stable
|
|
194
|
+
Showing: 5 of 210
|
|
195
|
+
|
|
196
|
+
File Score % Churn Nest Tier
|
|
197
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
src/utils/effect-generator.ts 408 31.8 68 6 🔴 DANGER
|
|
199
|
+
src/services/game-engine.ts 255 19.8 51 5 🔴 DANGER
|
|
200
|
+
src/components/board-renderer.tsx 210 16.4 42 5 🟡 WATCH
|
|
201
|
+
src/hooks/use-game-state.ts 99 7.7 33 3 🟡 WATCH
|
|
202
|
+
src/utils/move-validator.ts 54 4.2 27 2 🟡 WATCH
|
|
178
203
|
|
|
179
|
-
Score=
|
|
204
|
+
Score=metric×churn | Tiers are relative to THIS codebase, not absolute quality grades.
|
|
205
|
+
High scores flag review candidates, not bad code — stable complex files (parsers, engines) score high naturally.
|
|
180
206
|
Docs: https://github.com/wbern/obscene#metrics
|
|
181
207
|
```
|
|
182
208
|
|
package/dist/cli.js
CHANGED
|
@@ -168,6 +168,108 @@ function getCoChanges(months, excludes = []) {
|
|
|
168
168
|
}
|
|
169
169
|
return cochanges;
|
|
170
170
|
}
|
|
171
|
+
function assignTiers(items, totalScore) {
|
|
172
|
+
let cumulative = 0;
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
item.percentOfTotal = Math.round(item.score / totalScore * 1e3) / 10;
|
|
175
|
+
cumulative += item.score;
|
|
176
|
+
const cumulativeShare = cumulative / totalScore;
|
|
177
|
+
if (cumulativeShare <= DANGER_CUMULATIVE) {
|
|
178
|
+
item.tier = "danger";
|
|
179
|
+
} else if (cumulativeShare <= WATCH_CUMULATIVE) {
|
|
180
|
+
item.tier = "watch";
|
|
181
|
+
} else {
|
|
182
|
+
item.tier = "stable";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
var RANKING_DEFS = [
|
|
187
|
+
{
|
|
188
|
+
key: "complexity",
|
|
189
|
+
label: "Complexity \xD7 Churn",
|
|
190
|
+
scoreFormula: "complexity \xD7 churn"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: "nesting",
|
|
194
|
+
label: "Nesting \xD7 Churn",
|
|
195
|
+
scoreFormula: "maxNesting \xD7 churn"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: "defects",
|
|
199
|
+
label: "Defects \xD7 Churn",
|
|
200
|
+
scoreFormula: "defects \xD7 churn"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
key: "authors",
|
|
204
|
+
label: "Authors \xD7 Churn",
|
|
205
|
+
scoreFormula: "authors \xD7 churn"
|
|
206
|
+
}
|
|
207
|
+
];
|
|
208
|
+
function computeRanking(files, churn, metricExtractor, densityExtractor) {
|
|
209
|
+
const scored = files.map((f) => {
|
|
210
|
+
const fileChurn = churn.get(f.file) ?? 0;
|
|
211
|
+
const metricValue = metricExtractor(f);
|
|
212
|
+
return {
|
|
213
|
+
file: f.file,
|
|
214
|
+
score: metricValue * fileChurn,
|
|
215
|
+
percentOfTotal: 0,
|
|
216
|
+
tier: "stable",
|
|
217
|
+
churn: fileChurn,
|
|
218
|
+
metricValue,
|
|
219
|
+
metricDensity: densityExtractor ? densityExtractor(f) : void 0
|
|
220
|
+
};
|
|
221
|
+
}).filter((e) => e.score > 0).sort((a, b) => b.score - a.score);
|
|
222
|
+
const totalScore = scored.reduce((sum, e) => sum + e.score, 0);
|
|
223
|
+
if (totalScore === 0) return [];
|
|
224
|
+
assignTiers(scored, totalScore);
|
|
225
|
+
return scored;
|
|
226
|
+
}
|
|
227
|
+
function computeAllRankings(files, churn, defects, nestingDepths, authors, top) {
|
|
228
|
+
const extractors = {
|
|
229
|
+
complexity: {
|
|
230
|
+
extract: (f) => f.complexity,
|
|
231
|
+
density: (f) => f.complexityDensity
|
|
232
|
+
},
|
|
233
|
+
nesting: {
|
|
234
|
+
extract: (f) => nestingDepths.get(f.file) ?? 0
|
|
235
|
+
},
|
|
236
|
+
defects: {
|
|
237
|
+
extract: (f) => defects.get(f.file) ?? 0,
|
|
238
|
+
density: (f) => {
|
|
239
|
+
const d = defects.get(f.file) ?? 0;
|
|
240
|
+
return f.code > 0 ? Math.round(d / f.code * 1e4) / 1e4 : 0;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
authors: {
|
|
244
|
+
extract: (f) => authors.get(f.file) ?? 0
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const rankings = {};
|
|
248
|
+
for (const def of RANKING_DEFS) {
|
|
249
|
+
const ext = extractors[def.key];
|
|
250
|
+
const allEntries = computeRanking(files, churn, ext.extract, ext.density);
|
|
251
|
+
if (allEntries.length === 0) continue;
|
|
252
|
+
const limited = top > 0 ? allEntries.slice(0, top) : allEntries;
|
|
253
|
+
const tierCounts = {
|
|
254
|
+
danger: 0,
|
|
255
|
+
watch: 0,
|
|
256
|
+
stable: 0
|
|
257
|
+
};
|
|
258
|
+
for (const e of allEntries) {
|
|
259
|
+
tierCounts[e.tier]++;
|
|
260
|
+
}
|
|
261
|
+
rankings[def.key] = {
|
|
262
|
+
label: def.label,
|
|
263
|
+
scoreFormula: def.scoreFormula,
|
|
264
|
+
totalScore: allEntries.reduce((sum, e) => sum + e.score, 0),
|
|
265
|
+
tierCounts,
|
|
266
|
+
totalEntries: allEntries.length,
|
|
267
|
+
showing: limited.length,
|
|
268
|
+
entries: limited
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return rankings;
|
|
272
|
+
}
|
|
171
273
|
function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
|
|
172
274
|
const entries = [];
|
|
173
275
|
for (const [key, count] of cochanges) {
|
|
@@ -190,18 +292,14 @@ function computeCoupling(cochanges, churn, complexityMap, minCochanges) {
|
|
|
190
292
|
entries.sort((a, b) => b.couplingScore - a.couplingScore);
|
|
191
293
|
const totalScore = entries.reduce((sum, e) => sum + e.couplingScore, 0);
|
|
192
294
|
if (totalScore === 0) return [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
entry.tier = "watch";
|
|
202
|
-
} else {
|
|
203
|
-
entry.tier = "stable";
|
|
204
|
-
}
|
|
295
|
+
const adapted = entries.map((e) => ({
|
|
296
|
+
...e,
|
|
297
|
+
score: e.couplingScore
|
|
298
|
+
}));
|
|
299
|
+
assignTiers(adapted, totalScore);
|
|
300
|
+
for (let i = 0; i < entries.length; i++) {
|
|
301
|
+
entries[i].percentOfTotal = adapted[i].percentOfTotal;
|
|
302
|
+
entries[i].tier = adapted[i].tier;
|
|
205
303
|
}
|
|
206
304
|
return entries;
|
|
207
305
|
}
|
|
@@ -246,37 +344,62 @@ function getNestingDepths(filePaths) {
|
|
|
246
344
|
}
|
|
247
345
|
return depths;
|
|
248
346
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
} else if (cumulativeShare <= WATCH_CUMULATIVE) {
|
|
274
|
-
tier = "watch";
|
|
275
|
-
} else {
|
|
276
|
-
tier = "stable";
|
|
347
|
+
|
|
348
|
+
// src/color.ts
|
|
349
|
+
import pc from "picocolors";
|
|
350
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
351
|
+
function isWide(cp) {
|
|
352
|
+
return (
|
|
353
|
+
// CJK Radicals through Katakana (U+2E80–U+30FF) + CJK Symbols (U+3000–U+303F)
|
|
354
|
+
cp >= 11904 && cp <= 12543 || // Enclosed CJK Letters + CJK Compatibility (U+3200–U+33FF)
|
|
355
|
+
cp >= 12800 && cp <= 13311 || // CJK Extension A (U+3400–U+4DBF) + CJK Unified Ideographs (U+4E00–U+9FFF)
|
|
356
|
+
cp >= 13312 && cp <= 40959 || // Hangul Syllables (U+AC00–U+D7AF)
|
|
357
|
+
cp >= 44032 && cp <= 55215 || // CJK Compatibility Ideographs (U+F900–U+FAFF)
|
|
358
|
+
cp >= 63744 && cp <= 64255 || // Fullwidth Forms (U+FF01–U+FF60, U+FFE0–U+FFE6)
|
|
359
|
+
cp >= 65281 && cp <= 65376 || cp >= 65504 && cp <= 65510 || // Emoji and symbol blocks in supplementary planes (U+1F300–U+1FAFF)
|
|
360
|
+
cp >= 127744 && cp <= 129791 || // CJK Extension B+ and supplementary ideographs (U+20000–U+2FA1F)
|
|
361
|
+
cp >= 131072 && cp <= 195103
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
function visualWidth(s) {
|
|
365
|
+
const stripped = s.replace(ANSI_RE, "");
|
|
366
|
+
let width = 0;
|
|
367
|
+
for (const ch of stripped) {
|
|
368
|
+
const cp = ch.codePointAt(0);
|
|
369
|
+
if (cp !== void 0) {
|
|
370
|
+
width += isWide(cp) ? 2 : 1;
|
|
277
371
|
}
|
|
278
|
-
|
|
279
|
-
|
|
372
|
+
}
|
|
373
|
+
return width;
|
|
374
|
+
}
|
|
375
|
+
function padRight(s, n) {
|
|
376
|
+
const w = visualWidth(s);
|
|
377
|
+
return w >= n ? s : s + " ".repeat(n - w);
|
|
378
|
+
}
|
|
379
|
+
function padLeft(s, n) {
|
|
380
|
+
const w = visualWidth(s);
|
|
381
|
+
return w >= n ? s : " ".repeat(n - w) + s;
|
|
382
|
+
}
|
|
383
|
+
function truncate(s, max) {
|
|
384
|
+
return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
|
|
385
|
+
}
|
|
386
|
+
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");
|
|
390
|
+
}
|
|
391
|
+
function colorRow(tier, text) {
|
|
392
|
+
if (tier === "danger") return pc.red(text);
|
|
393
|
+
if (tier === "watch") return pc.yellow(text);
|
|
394
|
+
return pc.green(text);
|
|
395
|
+
}
|
|
396
|
+
function tierSummary(tierCounts, showing, total) {
|
|
397
|
+
const lines = [];
|
|
398
|
+
lines.push(
|
|
399
|
+
`Tiers: ${pc.red(`${tierCounts.danger} danger`)}, ${pc.yellow(`${tierCounts.watch} watch`)}, ${pc.green(`${tierCounts.stable} stable`)}`
|
|
400
|
+
);
|
|
401
|
+
lines.push(`Showing: ${showing} of ${total}`);
|
|
402
|
+
return lines;
|
|
280
403
|
}
|
|
281
404
|
|
|
282
405
|
// src/format.ts
|
|
@@ -309,28 +432,129 @@ function formatReportTable(output) {
|
|
|
309
432
|
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
310
433
|
return lines.join("\n");
|
|
311
434
|
}
|
|
312
|
-
function
|
|
435
|
+
function getRankingColumns(key) {
|
|
436
|
+
const base = [
|
|
437
|
+
{
|
|
438
|
+
header: "File",
|
|
439
|
+
width: 50,
|
|
440
|
+
align: "left",
|
|
441
|
+
value: (e) => truncate(e.file, 48)
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
header: "Score",
|
|
445
|
+
width: 8,
|
|
446
|
+
align: "right",
|
|
447
|
+
value: (e) => e.score.toLocaleString()
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
header: "%",
|
|
451
|
+
width: 7,
|
|
452
|
+
align: "right",
|
|
453
|
+
value: (e) => e.percentOfTotal.toFixed(1)
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
header: "Churn",
|
|
457
|
+
width: 7,
|
|
458
|
+
align: "right",
|
|
459
|
+
value: (e) => String(e.churn)
|
|
460
|
+
}
|
|
461
|
+
];
|
|
462
|
+
const metricCols = {
|
|
463
|
+
complexity: [
|
|
464
|
+
{
|
|
465
|
+
header: "Cmplx",
|
|
466
|
+
width: 7,
|
|
467
|
+
align: "right",
|
|
468
|
+
value: (e) => String(e.metricValue)
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
header: "Dens",
|
|
472
|
+
width: 7,
|
|
473
|
+
align: "right",
|
|
474
|
+
value: (e) => (e.metricDensity ?? 0).toFixed(2)
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
nesting: [
|
|
478
|
+
{
|
|
479
|
+
header: "Nest",
|
|
480
|
+
width: 6,
|
|
481
|
+
align: "right",
|
|
482
|
+
value: (e) => String(e.metricValue)
|
|
483
|
+
}
|
|
484
|
+
],
|
|
485
|
+
defects: [
|
|
486
|
+
{
|
|
487
|
+
header: "Dfcts",
|
|
488
|
+
width: 6,
|
|
489
|
+
align: "right",
|
|
490
|
+
value: (e) => String(e.metricValue)
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
header: "DfDns",
|
|
494
|
+
width: 7,
|
|
495
|
+
align: "right",
|
|
496
|
+
value: (e) => (e.metricDensity ?? 0).toFixed(4)
|
|
497
|
+
}
|
|
498
|
+
],
|
|
499
|
+
authors: [
|
|
500
|
+
{
|
|
501
|
+
header: "Auth",
|
|
502
|
+
width: 6,
|
|
503
|
+
align: "right",
|
|
504
|
+
value: (e) => String(e.metricValue)
|
|
505
|
+
}
|
|
506
|
+
]
|
|
507
|
+
};
|
|
508
|
+
const tierCol = {
|
|
509
|
+
header: "Tier",
|
|
510
|
+
width: 12,
|
|
511
|
+
align: "right",
|
|
512
|
+
value: (e) => tierLabel(e.tier)
|
|
513
|
+
};
|
|
514
|
+
return [...base, ...metricCols[key] ?? [], tierCol];
|
|
515
|
+
}
|
|
516
|
+
function formatRankingTable(key, ranking) {
|
|
313
517
|
const lines = [];
|
|
314
|
-
const
|
|
518
|
+
const cols = getRankingColumns(key);
|
|
315
519
|
lines.push(
|
|
316
|
-
|
|
520
|
+
`${ranking.label} \u2014 Total score: ${ranking.totalScore.toLocaleString()}`
|
|
317
521
|
);
|
|
318
|
-
pushTierSummary(lines, tierCounts, output.showing, output.totalHotspots);
|
|
319
522
|
lines.push(
|
|
320
|
-
|
|
523
|
+
...tierSummary(ranking.tierCounts, ranking.showing, ranking.totalEntries)
|
|
321
524
|
);
|
|
322
|
-
lines.push("
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
525
|
+
lines.push("");
|
|
526
|
+
const headerLine = cols.map(
|
|
527
|
+
(c) => c.align === "left" ? padRight(c.header, c.width) : padLeft(c.header, c.width)
|
|
528
|
+
).join("");
|
|
529
|
+
lines.push(headerLine);
|
|
530
|
+
const totalWidth = cols.reduce((sum, c) => sum + c.width, 0);
|
|
531
|
+
lines.push("\u2500".repeat(totalWidth));
|
|
532
|
+
for (const entry of ranking.entries) {
|
|
533
|
+
const rowParts = cols.map((c) => {
|
|
534
|
+
const val = c.value(entry);
|
|
535
|
+
return c.align === "left" ? padRight(val, c.width) : padLeft(val, c.width);
|
|
536
|
+
});
|
|
537
|
+
const rawRow = rowParts.join("");
|
|
538
|
+
lines.push(colorRow(entry.tier, rawRow));
|
|
539
|
+
}
|
|
540
|
+
return lines;
|
|
541
|
+
}
|
|
542
|
+
function formatHotspotsTable(output) {
|
|
543
|
+
const lines = [];
|
|
544
|
+
const { churnWindow, rankings } = output;
|
|
545
|
+
lines.push(`Hotspots \u2014 ${churnWindow} churn window`);
|
|
546
|
+
lines.push("");
|
|
547
|
+
const keys = Object.keys(rankings);
|
|
548
|
+
for (let i = 0; i < keys.length; i++) {
|
|
549
|
+
const key = keys[i];
|
|
550
|
+
lines.push(...formatRankingTable(key, rankings[key]));
|
|
551
|
+
if (i < keys.length - 1) {
|
|
552
|
+
lines.push("");
|
|
553
|
+
}
|
|
327
554
|
}
|
|
328
555
|
lines.push("");
|
|
329
556
|
lines.push(
|
|
330
|
-
"Score=
|
|
331
|
-
);
|
|
332
|
-
lines.push(
|
|
333
|
-
"Tiers are relative to THIS codebase, not absolute quality grades. A 'danger' file in a clean codebase may be fine."
|
|
557
|
+
"Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."
|
|
334
558
|
);
|
|
335
559
|
lines.push(
|
|
336
560
|
"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally."
|
|
@@ -344,15 +568,14 @@ function formatCouplingTable(output) {
|
|
|
344
568
|
lines.push(
|
|
345
569
|
`Coupling \u2014 ${churnWindow} churn window | Min shared: ${output.minCochanges} | Total score: ${totalScore.toLocaleString()}`
|
|
346
570
|
);
|
|
347
|
-
|
|
571
|
+
lines.push(...tierSummary(tierCounts, output.showing, output.totalCouplings));
|
|
348
572
|
lines.push(
|
|
349
|
-
padRight("File 1", 35) + padRight("File 2", 35) + padLeft("Shared", 7) + padLeft("Degree", 8) + padLeft("Cmplx", 7) + padLeft("Tier",
|
|
573
|
+
padRight("File 1", 35) + padRight("File 2", 35) + padLeft("Shared", 7) + padLeft("Degree", 8) + padLeft("Cmplx", 7) + padLeft("Tier", 12)
|
|
350
574
|
);
|
|
351
|
-
lines.push("\u2500".repeat(
|
|
575
|
+
lines.push("\u2500".repeat(104));
|
|
352
576
|
for (const c of couplings) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
577
|
+
const rawRow = padRight(truncate(c.file1, 33), 35) + padRight(truncate(c.file2, 33), 35) + padLeft(String(c.cochanges), 7) + padLeft(`${c.degree.toFixed(1)}%`, 8) + padLeft(String(c.totalComplexity), 7) + padLeft(tierLabel(c.tier), 12);
|
|
578
|
+
lines.push(colorRow(c.tier, rawRow));
|
|
356
579
|
}
|
|
357
580
|
lines.push("");
|
|
358
581
|
lines.push(
|
|
@@ -367,44 +590,22 @@ function formatCouplingTable(output) {
|
|
|
367
590
|
lines.push("Docs: https://github.com/wbern/obscene#metrics");
|
|
368
591
|
return lines.join("\n");
|
|
369
592
|
}
|
|
370
|
-
function pushTierSummary(lines, tierCounts, showing, total) {
|
|
371
|
-
lines.push(
|
|
372
|
-
`Tiers: ${tierCounts.danger} danger, ${tierCounts.watch} watch, ${tierCounts.stable} stable`
|
|
373
|
-
);
|
|
374
|
-
lines.push(`Showing: ${showing} of ${total}`);
|
|
375
|
-
lines.push("");
|
|
376
|
-
}
|
|
377
|
-
function tierLabel(tier) {
|
|
378
|
-
if (tier === "danger") return "DANGER";
|
|
379
|
-
if (tier === "watch") return "WATCH";
|
|
380
|
-
return "stable";
|
|
381
|
-
}
|
|
382
|
-
function padRight(s, n) {
|
|
383
|
-
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
384
|
-
}
|
|
385
|
-
function padLeft(s, n) {
|
|
386
|
-
return s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
387
|
-
}
|
|
388
|
-
function truncate(s, max) {
|
|
389
|
-
return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
|
|
390
|
-
}
|
|
391
593
|
|
|
392
594
|
// src/cli.ts
|
|
393
595
|
var program = new Command();
|
|
394
|
-
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.
|
|
596
|
+
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.0.1");
|
|
395
597
|
var REPORT_GUIDE = {
|
|
396
598
|
complexity: "Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",
|
|
397
599
|
complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
|
|
398
600
|
comments: "Comment line count. Low comments in high-density files may indicate under-documented logic. High comments alone is not a problem."
|
|
399
601
|
};
|
|
400
602
|
var HOTSPOTS_GUIDE = {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
defects: "
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
authors: "Unique committers in the time window. High author count may indicate unclear ownership. Low count is normal for specialized code. Neither value is inherently good or bad."
|
|
603
|
+
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.",
|
|
604
|
+
complexity: "complexity \xD7 churn. Ranks files by combined risk: complex code that changes often.",
|
|
605
|
+
nesting: "maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.",
|
|
606
|
+
defects: "defects \xD7 churn. Files with fix: commits that also churn heavily may contain latent bugs.",
|
|
607
|
+
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."
|
|
408
609
|
};
|
|
409
610
|
var COUPLING_GUIDE = {
|
|
410
611
|
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.",
|
|
@@ -486,28 +687,19 @@ function runHotspots(opts) {
|
|
|
486
687
|
const defects = getDefects(months);
|
|
487
688
|
const authors = getAuthors(months);
|
|
488
689
|
const nestingDepths = getNestingDepths(files.map((f) => f.file));
|
|
489
|
-
const
|
|
690
|
+
const rankings = computeAllRankings(
|
|
490
691
|
files,
|
|
491
692
|
churn,
|
|
492
693
|
defects,
|
|
493
694
|
nestingDepths,
|
|
494
|
-
authors
|
|
695
|
+
authors,
|
|
696
|
+
top
|
|
495
697
|
);
|
|
496
|
-
const limited = top > 0 ? hotspots.slice(0, top) : hotspots;
|
|
497
|
-
const tierCounts = { danger: 0, watch: 0, stable: 0 };
|
|
498
|
-
for (const h of hotspots) {
|
|
499
|
-
tierCounts[h.tier]++;
|
|
500
|
-
}
|
|
501
|
-
const totalScore = hotspots.reduce((sum, h) => sum + h.hotspotScore, 0);
|
|
502
698
|
const output = {
|
|
503
699
|
generated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
504
700
|
guide: HOTSPOTS_GUIDE,
|
|
505
701
|
churnWindow: `${months} months`,
|
|
506
|
-
|
|
507
|
-
tierCounts,
|
|
508
|
-
totalHotspots: hotspots.length,
|
|
509
|
-
showing: limited.length,
|
|
510
|
-
hotspots: limited
|
|
702
|
+
rankings
|
|
511
703
|
};
|
|
512
704
|
if (opts.format === "table") {
|
|
513
705
|
process.stdout.write(`${formatHotspotsTable(output)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbern/obscene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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": {
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"node": ">=18"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"commander": "^13.1.0"
|
|
51
|
+
"commander": "^13.1.0",
|
|
52
|
+
"picocolors": "^1.1.1"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@biomejs/biome": "^2.0.0",
|