@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.
Files changed (3) hide show
  1. package/README.md +75 -33
  2. package/dist/cli.js +144 -2
  3. 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
- | **danger** | top 50% of total score | Refactor candidates |
84
- | **watch** | next 30% (50–80%) | Keep an eye on these |
85
- | **stable** | bottom 20% | Low risk |
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). Tables with no scored entries are omitted.
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
- | **danger** | top 50% of total score | Refactor candidates |
172
- | **watch** | next 30% (50–80%) | Keep an eye on these |
173
- | **stable** | bottom 20% | Low risk |
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
- Complexity × Churn — Total score: 35,452
181
- Tiers: 3 danger, 13 watch, 194 stable
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 🔴 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
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 🔴 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
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 danger, 7 watch, 7 stable
234
+ Tiers: 10 HOT, 7 WARM, 7 COOL
214
235
  Showing: 5 of 24
215
236
 
216
- File 1 File 2 Shared Degree Cmplx Tier
217
- ────────────────────────────────────────────────────────────────────────────────────────────────────
218
- …ePlayer/hooks/useChessEffects.ts src/utils/effect-generator.ts 6 46.2% 261 DANGER
219
- …ePlayer/hooks/useChessEffects.ts src/utils/pgn-types.ts 6 50.0% 121 DANGER
220
- src/test/pgn-fixtures.ts src/utils/pgn-parser.server.ts 5 71.4% 3 DANGER
221
- src/test/pgn-fixtures.ts src/utils/effect-generator.ts 4 57.1% 145 DANGER
222
- src/test/pgn-fixtures.ts src/utils/pgn-types.ts 4 57.1% 5 DANGER
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 and generated files are excluded automatically: `*.test.*`, `*.spec.*`, `__tests__/`, `__mocks__/`, `*.stories.*`, `*.d.ts`, and similar patterns. scc also skips generated files by default (`--no-gen`).
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.3.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}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.3.1",
3
+ "version": "1.5.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": {