@wbern/obscene 1.3.1 → 1.5.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/README.md +75 -33
- package/dist/cli.js +144 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,15 +76,19 @@ Produces **four independent ranking tables**, each scoring files by a different
|
|
|
76
76
|
| Defects × Churn | `defects × churn` | Dfcts, DfDns |
|
|
77
77
|
| Authors × Churn | `authors × churn` | Auth |
|
|
78
78
|
|
|
79
|
+
Plus a **Combined** ranking using [Reciprocal Rank Fusion](https://doi.org/10.1145/1571941.1572114) (RRF) across all dimensions — files appearing near the top of multiple rankings score highest.
|
|
80
|
+
|
|
79
81
|
Each table has its own tier assignment by cumulative score distribution:
|
|
80
82
|
|
|
81
83
|
| Tier | Range | Meaning |
|
|
82
84
|
|------|-------|---------|
|
|
83
|
-
| **
|
|
84
|
-
| **
|
|
85
|
-
| **
|
|
85
|
+
| 🔥 **hot** | top 50% of total score | Highest churn × metric load |
|
|
86
|
+
| ☀️ **warm** | next 30% (50–80%) | Moderate load |
|
|
87
|
+
| 🧊 **cool** | bottom 20% | Low load |
|
|
88
|
+
|
|
89
|
+
Tiers are relative to THIS codebase, not absolute quality grades. A "hot" file is under heavy load, not necessarily broken.
|
|
86
90
|
|
|
87
|
-
A file may rank high in one dimension (e.g. complexity) but low in another (e.g. authors).
|
|
91
|
+
A file may rank high in one dimension (e.g. complexity) but low in another (e.g. authors). Rankings with insufficient data are skipped with an explanation (e.g. defects ranking requires 5+ `fix:` commits across 3+ files). Bot authors (`[bot]` suffix) are filtered automatically.
|
|
88
92
|
|
|
89
93
|
### `obscene coupling`
|
|
90
94
|
|
|
@@ -110,7 +114,7 @@ Per-file complexity without churn. Useful for raw complexity distribution.
|
|
|
110
114
|
| `--months <n>` | `3` | Churn window in months |
|
|
111
115
|
| `--format <type>` | `json` | `json` or `table` |
|
|
112
116
|
| `--min-cochanges <n>` | `2` | Minimum shared commits (coupling only) |
|
|
113
|
-
| `--exclude <patterns...>` | — | Additional exclusion patterns |
|
|
117
|
+
| `--exclude <patterns...>` | — | Additional exclusion patterns (also reads `.obsignore` / `.obsceneignore`) |
|
|
114
118
|
|
|
115
119
|
## Metrics
|
|
116
120
|
|
|
@@ -146,7 +150,7 @@ Maximum indentation level (tab stops) in the file. Deep nesting correlates with
|
|
|
146
150
|
|
|
147
151
|
#### Unique authors (`Auth`)
|
|
148
152
|
|
|
149
|
-
Number of distinct git authors who committed to the file within the churn window. Files touched by many authors may lack clear ownership and accumulate inconsistent patterns. Kamei et al. (2013) found developer count to be a significant predictor of defect-introducing changes.
|
|
153
|
+
Number of distinct git authors who committed to the file within the churn window. Bot authors (names ending in `[bot]`, e.g. `dependabot[bot]`) are excluded automatically. Files touched by many authors may lack clear ownership and accumulate inconsistent patterns. Kamei et al. (2013) found developer count to be a significant predictor of defect-introducing changes.
|
|
150
154
|
|
|
151
155
|
### Coupling metrics
|
|
152
156
|
|
|
@@ -168,38 +172,55 @@ Cumulative score distribution bucket:
|
|
|
168
172
|
|
|
169
173
|
| Tier | Range | Meaning |
|
|
170
174
|
|------|-------|---------|
|
|
171
|
-
| **
|
|
172
|
-
| **
|
|
173
|
-
| **
|
|
175
|
+
| 🔥 **hot** | top 50% of total score | Highest coupling load |
|
|
176
|
+
| ☀️ **warm** | next 30% (50–80%) | Moderate coupling |
|
|
177
|
+
| 🧊 **cool** | bottom 20% | Low coupling |
|
|
174
178
|
|
|
175
179
|
## Example output
|
|
176
180
|
|
|
177
181
|
```
|
|
178
182
|
Hotspots — 3 months churn window
|
|
179
183
|
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
🧬 COMPLEXITY × 🔄 CHURN — Total score: 35,452
|
|
185
|
+
complexity × churn. Complex code that changes often poses maintenance risk.
|
|
186
|
+
Tiers: 3 HOT, 13 WARM, 194 COOL
|
|
182
187
|
Showing: 5 of 210
|
|
183
188
|
|
|
184
189
|
File Score % Churn Cmplx Dens Tier
|
|
185
190
|
──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
186
|
-
src/utils/effect-generator.ts 8,296 23.4 68 122 0.12
|
|
187
|
-
src/services/game-engine.ts 4,284 12.1 51 84 0.09
|
|
188
|
-
src/components/board-renderer.tsx 2,940 8.3 42 70 0.11
|
|
189
|
-
src/hooks/use-game-state.ts 1,320 3.7 33 40 0.08
|
|
190
|
-
src/utils/move-validator.ts 945 2.7 27 35 0.06
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
src/utils/effect-generator.ts 8,296 23.4 68 122 0.12 🔥 HOT
|
|
192
|
+
src/services/game-engine.ts 4,284 12.1 51 84 0.09 🔥 HOT
|
|
193
|
+
src/components/board-renderer.tsx 2,940 8.3 42 70 0.11 🔥 HOT
|
|
194
|
+
src/hooks/use-game-state.ts 1,320 3.7 33 40 0.08 ☀️ WARM
|
|
195
|
+
src/utils/move-validator.ts 945 2.7 27 35 0.06 ☀️ WARM
|
|
196
|
+
|
|
197
|
+
· · ·
|
|
198
|
+
|
|
199
|
+
📏 NESTING × 🔄 CHURN — Total score: 1,284
|
|
200
|
+
maxNesting × churn. Deeply nested code that changes often is harder to reason about.
|
|
201
|
+
Tiers: 2 HOT, 5 WARM, 203 COOL
|
|
194
202
|
Showing: 5 of 210
|
|
195
203
|
|
|
196
204
|
File Score % Churn Nest Tier
|
|
197
205
|
────────────────────────────────────────────────────────────────────────────────────────
|
|
198
|
-
src/utils/effect-generator.ts 408 31.8 68 6
|
|
199
|
-
src/services/game-engine.ts 255 19.8 51 5
|
|
200
|
-
src/components/board-renderer.tsx 210 16.4 42 5
|
|
201
|
-
src/hooks/use-game-state.ts 99 7.7 33 3
|
|
202
|
-
src/utils/move-validator.ts 54 4.2 27 2
|
|
206
|
+
src/utils/effect-generator.ts 408 31.8 68 6 🔥 HOT
|
|
207
|
+
src/services/game-engine.ts 255 19.8 51 5 🔥 HOT
|
|
208
|
+
src/components/board-renderer.tsx 210 16.4 42 5 ☀️ WARM
|
|
209
|
+
src/hooks/use-game-state.ts 99 7.7 33 3 ☀️ WARM
|
|
210
|
+
src/utils/move-validator.ts 54 4.2 27 2 ☀️ WARM
|
|
211
|
+
|
|
212
|
+
════════════════════════════════════════════════════════════════════════════════════
|
|
213
|
+
★ COMBINED — Total score: 1.2345
|
|
214
|
+
Tiers: 3 HOT, 5 WARM, 202 COOL
|
|
215
|
+
Showing: 5 of 210
|
|
216
|
+
|
|
217
|
+
File Score % Churn Dims Tier
|
|
218
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
src/utils/effect-generator.ts 0.2727 22.1 68 4 🔥 HOT
|
|
220
|
+
src/services/game-engine.ts 0.1667 13.5 51 3 🔥 HOT
|
|
221
|
+
src/components/board-renderer.tsx 0.127 10.3 42 3 🔥 HOT
|
|
222
|
+
src/hooks/use-game-state.ts 0.0769 6.2 33 2 ☀️ WARM
|
|
223
|
+
src/utils/move-validator.ts 0.0667 5.4 27 2 ☀️ WARM
|
|
203
224
|
|
|
204
225
|
Score=metric×churn | Tiers are relative to THIS codebase, not absolute quality grades.
|
|
205
226
|
High scores flag review candidates, not bad code — stable complex files (parsers, engines) score high naturally.
|
|
@@ -210,18 +231,20 @@ Docs: https://github.com/wbern/obscene#metrics
|
|
|
210
231
|
|
|
211
232
|
```
|
|
212
233
|
Coupling — 6 months churn window | Min shared: 3 | Total score: 91
|
|
213
|
-
Tiers: 10
|
|
234
|
+
Tiers: 10 HOT, 7 WARM, 7 COOL
|
|
214
235
|
Showing: 5 of 24
|
|
215
236
|
|
|
216
|
-
File 1 File 2 Shared Degree Cmplx
|
|
217
|
-
|
|
218
|
-
…ePlayer/hooks/useChessEffects.ts src/utils/effect-generator.ts 6 46.2% 261
|
|
219
|
-
…ePlayer/hooks/useChessEffects.ts src/utils/pgn-types.ts 6 50.0% 121
|
|
220
|
-
src/test/pgn-fixtures.ts src/utils/pgn-parser.server.ts 5 71.4% 3
|
|
221
|
-
src/test/pgn-fixtures.ts src/utils/effect-generator.ts 4 57.1% 145
|
|
222
|
-
src/test/pgn-fixtures.ts src/utils/pgn-types.ts 4 57.1% 5
|
|
237
|
+
File 1 File 2 Shared Degree Cmplx Tier
|
|
238
|
+
──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
…ePlayer/hooks/useChessEffects.ts src/utils/effect-generator.ts 6 46.2% 261 🔥 HOT
|
|
240
|
+
…ePlayer/hooks/useChessEffects.ts src/utils/pgn-types.ts 6 50.0% 121 🔥 HOT
|
|
241
|
+
src/test/pgn-fixtures.ts src/utils/pgn-parser.server.ts 5 71.4% 3 🔥 HOT
|
|
242
|
+
src/test/pgn-fixtures.ts src/utils/effect-generator.ts 4 57.1% 145 🔥 HOT
|
|
243
|
+
src/test/pgn-fixtures.ts src/utils/pgn-types.ts 4 57.1% 5 🔥 HOT
|
|
223
244
|
|
|
224
245
|
Shared=co-changed commits | Degree=shared/min(churn)×100 | Cmplx=sum of both files
|
|
246
|
+
Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine.
|
|
247
|
+
Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown.
|
|
225
248
|
Docs: https://github.com/wbern/obscene#metrics
|
|
226
249
|
```
|
|
227
250
|
|
|
@@ -231,7 +254,26 @@ Any language [scc supports](https://github.com/boyter/scc#features) — 200+ lan
|
|
|
231
254
|
|
|
232
255
|
## Default exclusions
|
|
233
256
|
|
|
234
|
-
Test
|
|
257
|
+
Test files, lock files, and package manifests are excluded automatically: `*.test.*`, `*.spec.*`, `__tests__/`, `__mocks__/`, `*.stories.*`, `*.d.ts`, `package.json`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, and similar patterns. scc also skips generated files by default (`--no-gen`).
|
|
258
|
+
|
|
259
|
+
## Ignore files
|
|
260
|
+
|
|
261
|
+
Create a `.obsignore` or `.obsceneignore` file in your project root to persist exclusion patterns:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
# vendored code
|
|
265
|
+
vendor/**
|
|
266
|
+
|
|
267
|
+
# generated API clients
|
|
268
|
+
*.generated.*
|
|
269
|
+
src/api/generated/**
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- One glob pattern per line (same syntax as `--exclude`)
|
|
273
|
+
- Lines starting with `#` are comments
|
|
274
|
+
- Empty lines are ignored
|
|
275
|
+
- `.obsignore` takes priority if both files exist (they are not merged)
|
|
276
|
+
- CLI `--exclude` patterns are additive on top of ignore file patterns
|
|
235
277
|
|
|
236
278
|
## Why churn x complexity?
|
|
237
279
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { existsSync, writeFileSync } from "fs";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/analyze.ts
|
|
@@ -31,7 +32,12 @@ var DEFAULT_EXCLUDES = [
|
|
|
31
32
|
/__tests__\//,
|
|
32
33
|
/__mocks__\//,
|
|
33
34
|
/\.stories\./,
|
|
34
|
-
/\.d\.ts
|
|
35
|
+
/\.d\.ts$/,
|
|
36
|
+
/(?:^|\/)package\.json$/,
|
|
37
|
+
/(?:^|\/)package-lock\.json$/,
|
|
38
|
+
/(?:^|\/)pnpm-lock\.yaml$/,
|
|
39
|
+
/(?:^|\/)yarn\.lock$/,
|
|
40
|
+
/(?:^|\/)bun\.lock$/
|
|
35
41
|
];
|
|
36
42
|
var HOT_CUMULATIVE = 0.5;
|
|
37
43
|
var WARM_CUMULATIVE = 0.8;
|
|
@@ -380,6 +386,106 @@ function getNestingDepths(filePaths) {
|
|
|
380
386
|
}
|
|
381
387
|
return depths;
|
|
382
388
|
}
|
|
389
|
+
var INIT_DIR_RULES = [
|
|
390
|
+
{
|
|
391
|
+
dir: ".github",
|
|
392
|
+
pattern: ".github/**",
|
|
393
|
+
comment: "GitHub Actions and workflows"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
dir: ".circleci",
|
|
397
|
+
pattern: ".circleci/**",
|
|
398
|
+
comment: "CircleCI configuration"
|
|
399
|
+
},
|
|
400
|
+
{ dir: ".husky", pattern: ".husky/**", comment: "Git hooks" },
|
|
401
|
+
{ dir: ".vscode", pattern: ".vscode/**", comment: "VS Code settings" },
|
|
402
|
+
{ dir: ".idea", pattern: ".idea/**", comment: "JetBrains settings" },
|
|
403
|
+
{
|
|
404
|
+
dir: "scripts",
|
|
405
|
+
pattern: "scripts/**",
|
|
406
|
+
comment: "Build and utility scripts"
|
|
407
|
+
},
|
|
408
|
+
{ dir: "docs", pattern: "docs/**", comment: "Documentation" },
|
|
409
|
+
{ dir: "docker", pattern: "docker/**", comment: "Docker configuration" },
|
|
410
|
+
{
|
|
411
|
+
dir: "fixtures",
|
|
412
|
+
pattern: "fixtures/**",
|
|
413
|
+
comment: "Test fixtures"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
dir: "vendor",
|
|
417
|
+
pattern: "vendor/**",
|
|
418
|
+
comment: "Vendored dependencies"
|
|
419
|
+
}
|
|
420
|
+
];
|
|
421
|
+
var INIT_FILE_RULES = [
|
|
422
|
+
{
|
|
423
|
+
test: /\.generated\./,
|
|
424
|
+
pattern: "*.generated.*",
|
|
425
|
+
comment: "Generated code"
|
|
426
|
+
},
|
|
427
|
+
{ test: /\.gen\.[^.]+$/, pattern: "*.gen.*", comment: "Generated code" },
|
|
428
|
+
{
|
|
429
|
+
test: /\.config\.\w/,
|
|
430
|
+
pattern: "*.config.*",
|
|
431
|
+
comment: "Configuration files"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
test: /(?:^|\/)\.gitlab-ci/,
|
|
435
|
+
pattern: ".gitlab-ci*",
|
|
436
|
+
comment: "GitLab CI configuration"
|
|
437
|
+
}
|
|
438
|
+
];
|
|
439
|
+
function detectIgnorePatterns() {
|
|
440
|
+
let raw;
|
|
441
|
+
try {
|
|
442
|
+
raw = execSync("git ls-files", {
|
|
443
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
444
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
445
|
+
});
|
|
446
|
+
} catch {
|
|
447
|
+
throw new Error("Not a git repository or git is not installed.");
|
|
448
|
+
}
|
|
449
|
+
const trackedFiles = raw.toString().split("\n").map((l) => normalizePath(l.trim())).filter(Boolean);
|
|
450
|
+
const patterns = [];
|
|
451
|
+
const topDirs = /* @__PURE__ */ new Set();
|
|
452
|
+
for (const f of trackedFiles) {
|
|
453
|
+
const slash = f.indexOf("/");
|
|
454
|
+
if (slash > 0) topDirs.add(f.slice(0, slash));
|
|
455
|
+
}
|
|
456
|
+
for (const rule of INIT_DIR_RULES) {
|
|
457
|
+
if (topDirs.has(rule.dir)) {
|
|
458
|
+
patterns.push({ pattern: rule.pattern, comment: rule.comment });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const rule of INIT_FILE_RULES) {
|
|
462
|
+
if (trackedFiles.some((f) => rule.test.test(f))) {
|
|
463
|
+
patterns.push({ pattern: rule.pattern, comment: rule.comment });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return patterns;
|
|
467
|
+
}
|
|
468
|
+
function formatIgnoreFile(patterns) {
|
|
469
|
+
const lines = [
|
|
470
|
+
"# Generated by obscene init",
|
|
471
|
+
"# Edit this file to customize which files are excluded from analysis.",
|
|
472
|
+
"# Patterns use glob syntax (same as .gitignore).",
|
|
473
|
+
"# See: https://github.com/wbern/obscene#ignore-files",
|
|
474
|
+
""
|
|
475
|
+
];
|
|
476
|
+
if (patterns.length === 0) {
|
|
477
|
+
lines.push("# No project-specific patterns detected.");
|
|
478
|
+
lines.push("# Add glob patterns here, one per line.");
|
|
479
|
+
lines.push("");
|
|
480
|
+
} else {
|
|
481
|
+
for (const p of patterns) {
|
|
482
|
+
lines.push(`# ${p.comment}`);
|
|
483
|
+
lines.push(p.pattern);
|
|
484
|
+
lines.push("");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
383
489
|
var RRF_K = 10;
|
|
384
490
|
function computeComposite(rankings, churn, top) {
|
|
385
491
|
const totalDimensions = Object.keys(rankings).length;
|
|
@@ -751,7 +857,7 @@ function formatCompositeTable(output) {
|
|
|
751
857
|
|
|
752
858
|
// src/cli.ts
|
|
753
859
|
var program = new Command();
|
|
754
|
-
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.
|
|
860
|
+
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.5.0");
|
|
755
861
|
var REPORT_GUIDE = {
|
|
756
862
|
complexity: "Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",
|
|
757
863
|
complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
|
|
@@ -807,6 +913,13 @@ addSharedOptions(
|
|
|
807
913
|
exitWithError(err);
|
|
808
914
|
}
|
|
809
915
|
});
|
|
916
|
+
program.command("init").description("generate a starter .obsignore based on project structure").action(() => {
|
|
917
|
+
try {
|
|
918
|
+
runInit();
|
|
919
|
+
} catch (err) {
|
|
920
|
+
exitWithError(err);
|
|
921
|
+
}
|
|
922
|
+
});
|
|
810
923
|
function resolveExcludes(cliExcludes) {
|
|
811
924
|
return [...readIgnoreFile(), ...cliExcludes ?? []];
|
|
812
925
|
}
|
|
@@ -924,6 +1037,35 @@ function runCoupling(opts) {
|
|
|
924
1037
|
`);
|
|
925
1038
|
}
|
|
926
1039
|
}
|
|
1040
|
+
function runInit() {
|
|
1041
|
+
if (existsSync(".obsignore")) {
|
|
1042
|
+
throw new Error(
|
|
1043
|
+
".obsignore already exists. Remove it first to regenerate."
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
if (existsSync(".obsceneignore")) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
".obsceneignore already exists. Remove it first to regenerate."
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const patterns = detectIgnorePatterns();
|
|
1052
|
+
const content = formatIgnoreFile(patterns);
|
|
1053
|
+
writeFileSync(".obsignore", content);
|
|
1054
|
+
if (patterns.length === 0) {
|
|
1055
|
+
process.stderr.write(
|
|
1056
|
+
"Created .obsignore (no project-specific patterns detected)\n"
|
|
1057
|
+
);
|
|
1058
|
+
} else {
|
|
1059
|
+
process.stderr.write(
|
|
1060
|
+
`Created .obsignore with ${patterns.length} patterns:
|
|
1061
|
+
`
|
|
1062
|
+
);
|
|
1063
|
+
for (const p of patterns) {
|
|
1064
|
+
process.stderr.write(` ${p.pattern.padEnd(20)} ${p.comment}
|
|
1065
|
+
`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
927
1069
|
function exitWithError(err) {
|
|
928
1070
|
const message = err instanceof Error ? err.message : String(err);
|
|
929
1071
|
process.stderr.write(`Error: ${message}
|