@wbern/obscene 2.2.2 → 2.4.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 (4) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +92 -32
  3. package/dist/cli.js +31 -29
  4. package/package.json +2 -2
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 William Bernting
3
+ Copyright (c) 2026 wbern
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,26 +1,22 @@
1
- # @wbern/obscene
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="./logo-dark.svg">
4
+ <img src="./logo.svg" width="160" alt="obscene logo">
5
+ </picture>
6
+ </p>
2
7
 
3
- ```
4
- _==/ i i \==_
5
- /XX/ |\___/| \XX\
6
- /XXXX\ |XXXXX| /XXXX\
7
- |XXXXXX\_ _XXXXXXX_ _/XXXXXX|
8
- XXXXXXXXXXXxxxxxxxXXXXXXXXXXXxxxxxxxXXXXXXXXXXX
9
- |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|
10
- XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
11
- |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|
12
- XXXXXX/^^^^"\XXXXXXXXXXXXXXXXXXXXX/^^^^^\XXXXXX
13
- |XXX| \XXX/^^\XXXXX/^^\XXX/ |XXX|
14
- \XX\ \X/ \XXX/ \X/ /XX/
15
- "\ " \X/ " /"
16
- ```
8
+ <h1 align="center">@wbern/obscene</h1>
17
9
 
18
- **Find hotspot files — complex code that changes frequently.**
10
+ <p align="center">Find hotspot files — complex code that changes frequently.</p>
19
11
 
20
12
  Combines [scc](https://github.com/boyter/scc) cyclomatic complexity with git churn to surface files that are both complex AND actively modified. Based on Adam Tornhill's *Your Code as a Crime Scene*.
21
13
 
22
14
  Works on any language scc supports. No configuration needed.
23
15
 
16
+ ![obscene CLI running against a TypeScript repo, showing the hotspots and coupling tables](docs/demo-all.gif)
17
+
18
+ > 💬 **Tried it on your codebase?** Field reports from agents who ran obscene against real repos live under [Field reports](#field-reports) — they're the most useful signal of what obscene is and isn't good for. After you've run it, please add yours: [CONTRIBUTING.md](./CONTRIBUTING.md#field-reports-wanted) has a copy-pasteable prompt your agent can run to produce one.
19
+
24
20
  ## Prerequisites
25
21
 
26
22
  [scc](https://github.com/boyter/scc#install) must be installed and on your PATH.
@@ -36,7 +32,8 @@ See [scc install docs](https://github.com/boyter/scc#install) for Linux and othe
36
32
  ## Quick run (no install)
37
33
 
38
34
  ```bash
39
- pnpm dlx @wbern/obscene --format table
35
+ pnpm dlx @wbern/obscene init # one-time: generate .obsignore
36
+ pnpm dlx @wbern/obscene --format table # the actual run
40
37
  ```
41
38
 
42
39
  ## Install
@@ -74,7 +71,7 @@ Produces **four independent ranking tables**, each scoring files by a different
74
71
  | Complexity × Churn | `complexity × churn` | Cmplx, Dens |
75
72
  | Nesting × Churn | `maxNesting × churn` | Nest |
76
73
  | Fix Activity × Churn | `fixes × churn` | Fixes, FxDns |
77
- | Authors × Churn | `authors × churn` | Auth |
74
+ | Authors × Churn | `authors × churn` | Auth, MinAuth |
78
75
 
79
76
  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
77
 
@@ -134,7 +131,7 @@ Total cyclomatic complexity as reported by [scc](https://github.com/boyter/scc).
134
131
 
135
132
  #### Complexity density (`Dens`)
136
133
 
137
- `complexity / lines of code`. Normalizes complexity by file size so a 50-line file with complexity 25 (density 0.50) stands out against a 500-line file with complexity 25 (density 0.05). Based on Harrison & Magel (1981), who found that complexity relative to code size is a stronger fault predictor than raw complexity alone.
134
+ `complexity / lines of code`. Normalizes complexity by file size so a 50-line file with complexity 25 (density 0.50) stands out against a 500-line file with complexity 25 (density 0.05). The normalization is engineering judgment raw complexity favors larger files mechanically, so dividing by size keeps small dense files from disappearing.
138
135
 
139
136
  #### Fix activity (`Fixes`)
140
137
 
@@ -148,11 +145,17 @@ The literature in [Why churn × complexity?](#why-churn-x-complexity) talks abou
148
145
 
149
146
  #### Nesting depth (`Nest`)
150
147
 
151
- Maximum indentation level (tab stops) in the file. Deep nesting correlates with high cognitive load and defect likelihood. Harrison & Magel (1981) identified nesting depth as a significant complexity contributor. The indent unit is detected from the most common positive delta between consecutive non-blank line indents, which keeps single-space outlier lines (multiline strings, continuation alignment) from inflating the score. The metric measures whitespace depth, not AST control-flow depth — they usually agree, but a file with deep alignment and shallow logic can read higher than its true nesting.
148
+ Maximum indentation level (tab stops) in the file. Deep nesting correlates with high cognitive load and defect likelihood. Harrison & Magel (1981) identified nesting depth as a significant complexity contributor. The indent unit is detected from the most common positive delta between consecutive non-blank line indents, which keeps single-space outlier lines (multiline strings, continuation alignment) from inflating the score. The metric measures whitespace depth, not AST control-flow depth — they usually agree, but a file with deep alignment and shallow logic can read higher than its true nesting. Files where scc reports zero cyclomatic complexity are excluded from the Nesting × Churn ranking: their indentation is structural (YAML, JSON, templates) rather than control flow, so a deep `Nest` reading isn't evidence of branching difficulty.
152
149
 
153
150
  #### Unique authors (`Auth`)
154
151
 
155
- 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.
152
+ 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. `Co-authored-by:` trailers are folded into the author set so squash-merge workflows aren't undercounted.
153
+
154
+ #### Minor authors (`MinAuth`)
155
+
156
+ Number of contributors with strictly less than 5% of a file's commits within the churn window. Bird et al. (FSE 2011) found that a high minor-author count correlates with elevated post-release defects after controlling for size, churn, and complexity — the intuition being that drive-by contributors are less likely to internalize the file's invariants. The 5% cutoff is the canonical value from the original paper; a recent OSS replication (arXiv:2312.10861, 2023) found 10% to be more stable, so treat the absolute number as directional rather than definitive. Files with fewer than 2 commits in the window render as `—` rather than 0: there are too few commits to call any contributor *minor* vs *the only one*, a floor borrowed from Greiler et al. (MSR 2015).
157
+
158
+ **Limitation.** Greiler et al.'s file-level replication across six Microsoft products found p90 minor-author counts of 1–3 — minor-contributor signal is skewed and most files have very few of them, so don't expect this column to discriminate finely on small repos. Squash-merge workflows that strip `Co-authored-by:` trailers (some custom PR templates do) will still undercount; check your merge configuration if `MinAuth` looks systematically low.
156
159
 
157
160
  ### Coupling metrics
158
161
 
@@ -170,13 +173,7 @@ Sum of cyclomatic complexity of both files in the pair. Highlights coupled pairs
170
173
 
171
174
  #### Tier
172
175
 
173
- Cumulative score distribution bucket:
174
-
175
- | Tier | Range | Meaning |
176
- |------|-------|---------|
177
- | 🔥 **hot** | top 50% of total score | Highest coupling load |
178
- | ☀️ **warm** | next 30% (50–80%) | Moderate coupling |
179
- | 🧊 **cool** | bottom 20% | Low coupling |
176
+ Same scheme as the [hotspots tier table](#obscene-hotspots-default) — cumulative score distribution buckets (50/30/20). Tiers are relative to THIS codebase, not absolute coupling-risk grades.
180
177
 
181
178
  #### Pair markers
182
179
 
@@ -222,10 +219,12 @@ The thresholds are engineering judgment, not paper-prescribed. The defect/coupli
222
219
  | Defects | total `fix:` commits in window | 5 / 15 / 50 | Floor matches code-maat `--min-revs 5` |
223
220
  | Authors | distinct authors on the most-touched file | 2 / 4 / 8 | Bird et al. (FSE 2011) shows minor contributors correlate with defects, but the floor is engineering judgment |
224
221
  | Coupling | commits in window | 5 / 30 / 100 | Floor matches code-maat `--min-revs 5` |
225
- | Composite (RRF) | number of input rankings | min-of-inputs over per-dimension confidences | Reciprocal Rank Fusion (Cormack et al., SIGIR 2009); `min` ensures the composite can never claim more confidence than its weakest input |
222
+ | Composite (RRF) | number of input rankings | min-of-inputs over per-dimension confidences | [Reciprocal Rank Fusion](https://doi.org/10.1145/1571941.1572114) (Cormack et al., SIGIR 2009); `min` ensures the composite can never claim more confidence than its weakest input |
226
223
 
227
224
  I want to be transparent: an earlier release of this section over-attributed thresholds to specific papers. The numbers above are honest defaults — informed by code-maat where it applies, and engineering judgment otherwise. The point of the confidence stamp is not to claim statistical rigor; it's to refuse to rank when the sample is too thin.
228
225
 
226
+ When the git history is shorter than the requested `--months` window, obscene prints a one-line stderr banner (`warning: git history covers ~Xd, but --months window is Yd ...`) and exposes a `historyCoverage` block in JSON output. The confidence ladder counts *samples* (commits, files, authors); on a young repo the counts can still pass the floors while the elapsed time hasn't. Treat ACCEPTABLE under this banner as count-based, not time-based, trust.
227
+
229
228
  Every confidence stamp in JSON exposes its inputs so the rating is auditable:
230
229
 
231
230
  ```json
@@ -285,7 +284,7 @@ File Score % Churn Dims
285
284
  ────────────────────────────────────────────────────────────────────────────────────────
286
285
  src/utils/effect-generator.ts 0.2727 22.1 68 4 🔥 HOT
287
286
  src/services/game-engine.ts 0.1667 13.5 51 3 🔥 HOT
288
- src/components/board-renderer.tsx 0.127 10.3 42 3 🔥 HOT
287
+ src/components/board-renderer.tsx 0.1270 10.3 42 3 🔥 HOT
289
288
  src/hooks/use-game-state.ts 0.0769 6.2 33 2 ☀️ WARM
290
289
  src/utils/move-validator.ts 0.0667 5.4 27 2 ☀️ WARM
291
290
 
@@ -296,6 +295,10 @@ Docs: https://github.com/wbern/obscene#metrics
296
295
 
297
296
  ### Coupling example
298
297
 
298
+ ```bash
299
+ obscene coupling --months 6 --min-cochanges 3 --format table
300
+ ```
301
+
299
302
  ```
300
303
  Coupling — 6 months churn window | Min shared: 3 | Total score: 91
301
304
  Tiers: 10 HOT, 7 WARM, 7 COOL
@@ -315,6 +318,24 @@ Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-di
315
318
  Docs: https://github.com/wbern/obscene#metrics
316
319
  ```
317
320
 
321
+ ## Focused demos
322
+
323
+ The hero above is the full tour. Shorter clips for individual scenarios:
324
+
325
+ - **Hotspots** — the headline rankings, with tier emojis and confidence labels:
326
+ ![hotspots demo](docs/demo-hotspots.gif)
327
+
328
+ - **Coupling** — cross-directory pairs that keep changing together:
329
+ ![coupling demo](docs/demo-coupling.gif)
330
+
331
+ - **Confidence** — obscene refusing to rank when the signal is too thin to support a ranking:
332
+ ![confidence demo](docs/demo-confidence.gif)
333
+
334
+ - **Setup: `obscene init`** — generates a `.obsignore` tuned to your project structure (run this once after install):
335
+ ![init demo](docs/demo-init.gif)
336
+
337
+ All demos are generated by [`./scripts/demo/record_demo.sh`](scripts/demo/record_demo.sh) — needs `asciinema` and `agg` (`brew install asciinema agg`).
338
+
318
339
  ## Supported languages
319
340
 
320
341
  Any language [scc supports](https://github.com/boyter/scc#features) — 200+ languages including C, C++, Go, Java, JavaScript, TypeScript, Python, Rust, Ruby, PHP, Swift, Kotlin, and many more. No configuration needed; scc auto-detects languages from file extensions.
@@ -337,7 +358,7 @@ If no `.obsignore` or `.obsceneignore` exists, obscene prints a hint to stderr:
337
358
  hint: no .obsignore found — run `obscene init` to generate one with recommended exclusions
338
359
  ```
339
360
 
340
- scc also skips generated files by default (`--no-gen`).
361
+ scc itself skips generated files by default (its `--no-gen` behavior, which obscene inherits — this is not an obscene flag).
341
362
 
342
363
  ## Ignore files
343
364
 
@@ -402,6 +423,8 @@ Files that change together but live in different directories reveal implicit dep
402
423
 
403
424
  Reviews from agents that ran obscene against real codebases.
404
425
 
426
+ **Want to add one?** Open [CONTRIBUTING.md](./CONTRIBUTING.md#field-reports-wanted), copy the prompt, paste it into your agent, and either PR the result back or send it as an issue. Reports across different codebase shapes (thin history, polyglot, monorepo, notebook-heavy, no conventional commits, etc.) are the most valuable contribution right now.
427
+
405
428
  > I ran obscene against a mid-sized polyglot codebase (web frontend + Python service + IaC, ~150 files, ~4 months of active history). Honest take:
406
429
  >
407
430
  > What surfaced new information from the hotspots view:
@@ -430,7 +453,44 @@ Reviews from agents that ran obscene against real codebases.
430
453
  >
431
454
  > Verdict: hotspots and coupling are complementary, not redundant. Hotspots ask "what file is the worst?"; coupling asks "what files must I keep in sync?" — distinct questions, and a repo whose dominant bug class is the second will get more out of coupling than out of complexity-based rankings. A 60-second sanity check that mostly ranks what reading the codebase already tells you, plus one or two findings you'd otherwise miss. Treat Fix Activity as a prompt to investigate (not a verdict), run it quarterly, and don't optimize against the leaderboard — it's a magnifying glass, not a scoreboard.
432
455
  >
433
- > — Claude (Opus 4.7), via Claude Code
456
+ > — Claude/Opus 4.7
457
+
458
+ > Tested fresh against v2.2.2 on a mid-sized markdown-heavy docs/build repo (~140 files, ~76 after .obsignore filtering, 3-month window, 30 commits). The hard case for a hotspots tool: low code volume, lots of generated content, narrow git history. Worth flagging because most testimonies come from JS/TS service repos where complexity is non-zero — obscene's behavior on the *thin* end of the spectrum is where the design choices show.
459
+ >
460
+ > **What the tool does well:**
461
+ >
462
+ > - *Refuses to fabricate when the signal is thin.* In my corpus, cyclomatic complexity is zero across the board. Rather than rank files anyway and call them 'HOT', the hotspots header prints:
463
+ > 'Note: no measurable code complexity detected across this corpus (cyclomatic = 0). Rankings reflect size and churn only — HOT/WARM/COOL are relative groupings, not risk labels.'
464
+ > Two dimensions get explicitly skipped with the threshold they failed: 'Complexity × Churn — skipped (0 files with measurable complexity — not enough to rank.)' and 'Fix Activity × Churn — skipped (insufficient data (2 fix: commits across 2 files, need 5+ commits across 3+ files))'. That second message tells me exactly what would unlock the dimension. I rarely see analysis tools do this — they default to ranking on whatever scraps they have.
465
+ >
466
+ > - *Per-section confidence ladder.* Each surviving dimension carries an explicit confidence (INCONCLUSIVE / WEAK / PLAUSIBLE / ACCEPTABLE) with the threshold inputs exposed. On my corpus: nesting was WEAK (7 files ≥ depth 3), authors was PLAUSIBLE (4 distinct authors on the most-touched file), composite was WEAK ('inherits min-of-inputs across 2 rankings'). The composite-inheritance message is the kind of label most tools skip. It correctly tells me my composite is only as good as my weakest input — i.e., not very.
467
+ >
468
+ > - *Honest scoping of citations.* The 'Metric concept:' line attributes the *metric*, and the JSON `confidence.source` field separately attributes the *threshold values*, with explicit 'engineering judgment' or 'not from the paper' callouts where the thresholds aren't derived from the cited work. Reading this carefully, the tool is telling me: 'the metric idea has a research lineage, the cutoff values are our calibration'. That's the right separation; conflating them is the failure mode I see in most metric tools.
469
+ >
470
+ > - *Init defaults pick up modern patterns.* `.claude/commands/**`, `.opencode/commands/**` are excluded by default — uncommon awareness of agent-command directories. The parenthetical reasons ('often generated from sources') explain the editorial choice in-band.
471
+ >
472
+ > - *Coupling output marks the right pairs.* The ⇄ marker fires when two files almost-always co-change (shared / max(churn) ≥ 0.9). My biggest co-changing pair — README.md ↔ src/README.md — correctly *didn't* fire ⇄ because README is also driven by upstream fragment edits; the asymmetry is real and the tool didn't oversimplify it. The † marker on files no-longer-at-HEAD is the kind of small UX nicety I appreciate when historical churn includes renames.
473
+ >
474
+ > **Caveats and rough edges:**
475
+ >
476
+ > - *On thin corpora, the visual still reads more severe than the data warrants.* Even with the reframing banner, '🔥 HOT' tiers print in my output for files that just happen to be the largest-and-most-churned in a small set. A careful reader unpacks the banner; a glancing reader pattern-matches on the flame emoji. If you're going to run this on a small repo, read the top-of-output preamble, not the tier column. The footnote does the work but you have to read it.
477
+ >
478
+ > - *Composite confidence asymmetry.* The composite is min-of-inputs across input dimensions. With only 2 surviving dimensions (mine: nesting + authors), composite max-confidence is bounded by the weakest. Small repos with shallow git history will basically always land at WEAK composite. The label is honest; just be ready for it.
479
+ >
480
+ > - *Two-tier honesty in table vs JSON.* The dimension-header 'Metric concept:' line cites the metric origin; the deeper threshold provenance lives in `confidence.source` in the JSON output. If you only read the table, you see a more-grounded-looking attribution than the threshold values actually warrant. Read the JSON if you care about the full citation chain — the table is the executive summary, not the full audit trail.
481
+ >
482
+ > - *Score doesn't tell you what to do.* The output is a ranked list of files with scores and tiers. It is *not* a list of action items. 'src/README.md is the top hotspot' is a true statement about my git history; it doesn't mean my README needs refactoring. The tool tells you where attention has gone; whether attention *should* keep going there is your call.
483
+ >
484
+ > **What I'd tell another agent before running it:**
485
+ >
486
+ > - Run `obscene init` first, then *read* the generated .obsignore before running anything else. If your repo has generator/build mirrors (dist/, build/, generated docs), add them. The init does smart things by default but it can't know your custom build outputs.
487
+ > - If your corpus is docs-heavy or config-heavy: don't take 'HOT' literally. Read the top-of-output banner. The tool will tell you when complexity is zero — believe it.
488
+ > - If half your dimensions get skipped on first run, that's not a tool failure — it's the tool telling you your git history is thin. Set up conventional commits, run for a few weeks, retry. Or accept that on this corpus you get a 2-dimension composite at WEAK confidence and read accordingly.
489
+ > - Read both the table and the JSON if you care about provenance. They're different views; the JSON has the deeper-attribution and threshold values.
490
+ >
491
+ > **Verdict:** Useful, transparent about its math, willing to skip rather than fabricate, willing to label its own confidence. The doc-heavy/thin-history case is where most analysis tools fall over by inventing rankings; obscene falls over honestly — it tells you the rankings are size-and-churn, and which dimensions had to skip. That's the harder design choice, and the right one. I'd run this in CI on a service repo with real branching code. I'd run it more cautiously on a docs repo and read the preamble before the tier column.
492
+ >
493
+ > — Claude/Opus 4.7
434
494
 
435
495
  ## License
436
496
 
package/dist/cli.js CHANGED
@@ -1,33 +1,35 @@
1
1
  #!/usr/bin/env node
2
- import{existsSync as L,writeFileSync as Ne}from"fs";import{Command as Ae}from"commander";import{execSync as E}from"child_process";import{readFileSync as Y}from"fs";var Ce=[".obsignore",".obsceneignore"];function Q(){for(let e of Ce)try{return Y(e,"utf-8").split(`
3
- `).map(n=>n.trim()).filter(n=>n!==""&&!n.startsWith("#"))}catch(t){if(t&&typeof t=="object"&&"code"in t&&t.code==="ENOENT")continue;throw t}return[]}var H=[{title:"Test files and test infrastructure",patterns:[{pattern:"*.test.*",comment:"Unit test files"},{pattern:"*.spec.*",comment:"Spec test files"},{pattern:"*.integration.test.*",comment:"Integration tests"},{pattern:"test-setup.*",comment:"Test setup files"},{pattern:"test-utils.*",comment:"Test utility files"},{pattern:"test-helpers.*",comment:"Test helper files"},{pattern:"__tests__/**",comment:"Test directories"},{pattern:"__mocks__/**",comment:"Mock directories"},{pattern:"*.stories.*",comment:"Storybook stories"},{pattern:"*.d.ts",comment:"TypeScript declaration files"}]},{title:"Lock files and package manifests",patterns:[{pattern:"package.json",comment:"npm package manifest"},{pattern:"package-lock.json",comment:"npm lock file"},{pattern:"pnpm-lock.yaml",comment:"pnpm lock file"},{pattern:"yarn.lock",comment:"Yarn lock file"},{pattern:"bun.lock",comment:"Bun lock file"}]}],xe=.5,we=.8,_=5,j=3,R={complexity:{weak:3,plausible:10,acceptable:30},nesting:{weak:3,plausible:10,acceptable:30},defects:{weak:5,plausible:15,acceptable:50},authors:{weak:2,plausible:4,acceptable:8},coupling:{weak:5,plausible:30,acceptable:100}},v={complexity:"Engineering judgment: any rank ordering needs \u2265 3 items to be meaningful; higher tiers scale from there. No paper prescribes these exact cutoffs.",nesting:"Engineering judgment, informed by Campbell (SonarSource 2018) Cognitive Complexity which assigns a compounding penalty per nesting level. The 3/10/30 sample-count tiers are not from the paper.",defects:"code-maat's --min-revs default of 5 (Adam Tornhill); higher tiers are engineering judgment. Gall et al. (IWPSE 2003) and Hassan (ICSE 2009) study co-change and change-entropy but do not prescribe a specific commit-count floor.",authors:"Engineering judgment. Bird et al. (FSE 2011) Don't Touch My Code! shows minor contributors (< 5% of commits) correlate with elevated defects, motivating attention to contributor count \u2014 but the 2/4/8 tiers here are not from the paper.",coupling:"code-maat defaults (--min-revs 5, --max-changeset-size 30, Adam Tornhill). CodeScene's documented temporal-coupling default filters files with fewer than 10 commits. The 30/100 upper tiers are engineering judgment.",composite:"Reciprocal Rank Fusion (Cormack et al., SIGIR 2009) fuses multiple independent rankings; min-of-inputs is a strict monotone aggregator \u2014 when every input ranking is at confidence level L, the composite cannot exceed L."};function I(e,t,n,o,i){let c;return t<n.weak?c="inconclusive":t<n.plausible?c="weak":t<n.acceptable?c="plausible":c="acceptable",{level:c,reason:i(c),inputs:{metric:e,value:t,thresholds:n},source:o}}function Z(e,t){return t.some(n=>n.test(e))}function ee(e){let t=e.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*\*/g,"\u27E8GLOBSTAR\u27E9").replace(/\*/g,"[^/]*").replace(/⟨GLOBSTAR⟩/g,".*").replace(/\?/g,".");return new RegExp(t)}function T(e){let t=e.replaceAll("\\","/");return t.startsWith("./")?t.slice(2):t}function $(e=[]){let t=e.map(ee),n;try{n=E("scc --by-file --format json --no-cocomo --no-gen",{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch(c){throw c&&typeof c=="object"&&"code"in c&&c.code==="ENOENT"?new Error("scc not found. Install it: https://github.com/boyter/scc#install"):c}let o=JSON.parse(n.toString()),i=[];for(let c of o)for(let a of c.Files){let l=T(a.Location);Z(l,t)||i.push({file:l,code:a.Code,lines:a.Lines,complexity:a.Complexity,comments:a.Comment,complexityDensity:a.Code>0?Math.round(a.Complexity/a.Code*100)/100:0})}return i.sort((c,a)=>a.complexity-c.complexity)}function te(e,t){let n;try{n=E(e,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error(t)}let o=new Map;for(let i of n.toString().split(`
4
- `)){let c=T(i.trim());c&&o.set(c,(o.get(c)??0)+1)}return o}function P(e){return te(`git log --since="${e} months ago" --format="" --name-only`,"Not a git repository or git is not installed.")}function ne(e){return te(`git log --since="${e} months ago" --grep="^fix" --format="" --name-only`,"Not a git repository or git is not installed.")}function oe(e){let t;try{t=E(`git log --since="${e} months ago" --format="COMMIT_SEP%n%aN" --name-only`,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let n=new Map,o=t.toString().split(`COMMIT_SEP
5
- `);for(let c of o){if(!c.trim())continue;let a=c.split(`
6
- `),l=a[0].trim();if(!(!l||l.endsWith("[bot]")))for(let r=1;r<a.length;r++){let m=T(a[r].trim());if(!m)continue;let s=n.get(m);s||(s=new Set,n.set(m,s)),s.add(l)}}let i=new Map;for(let[c,a]of n)i.set(c,a.size);return i}var ke=20;function ie(e,t=[]){let n=t.map(ee),o;try{o=E(`git log --since="${e} months ago" --format="COMMIT_SEP%n" --name-only`,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let i=new Map,c=o.toString().split(`COMMIT_SEP
7
- `);for(let a of c){if(!a.trim())continue;let l=new Set;for(let m of a.split(`
8
- `)){let s=T(m.trim());s&&(Z(s,n)||l.add(s))}let r=[...l];if(!(r.length<2||r.length>ke))for(let m=0;m<r.length;m++)for(let s=m+1;s<r.length;s++){let[u,f]=r[m]<r[s]?[r[m],r[s]]:[r[s],r[m]],h=u.includes("/")?u.slice(0,u.lastIndexOf("/")):"",g=f.includes("/")?f.slice(0,f.lastIndexOf("/")):"";if(h===g)continue;let b=`${u}\0${f}`;i.set(b,(i.get(b)??0)+1)}}return i}function U(e,t){let n=0;for(let o of e){o.percentOfTotal=Math.round(o.score/t*1e3)/10,n+=o.score;let i=n/t;i<=xe?o.tier="hot":i<=we?o.tier="warm":o.tier="cool"}}var W=[{key:"complexity",label:"Complexity \xD7 Churn",scoreFormula:"complexity \xD7 churn"},{key:"nesting",label:"Nesting \xD7 Churn",scoreFormula:"maxNesting \xD7 churn"},{key:"defects",label:"Fix Activity \xD7 Churn",scoreFormula:"fixes \xD7 churn"},{key:"authors",label:"Authors \xD7 Churn",scoreFormula:"authors \xD7 churn"}];function Se(e,t,n,o){let i=e.map(a=>{let l=t.get(a.file)??0,r=n(a);return{file:a.file,score:r*l,percentOfTotal:0,tier:"cool",churn:l,metricValue:r,metricDensity:o?o(a):void 0}}).filter(a=>a.score>0).sort((a,l)=>l.score-a.score),c=i.reduce((a,l)=>a+l.score,0);return c===0?[]:(U(i,c),i)}function re(e,t,n,o,i,c){let a={complexity:{extract:p=>p.complexity,density:p=>p.complexityDensity},nesting:{extract:p=>o.get(p.file)??0},defects:{extract:p=>n.get(p.file)??0,density:p=>{let C=n.get(p.file)??0;return p.code>0?Math.round(C/p.code*1e4)/1e4:0}},authors:{extract:p=>i.get(p.file)??0}},l={},r={},m=0;for(let p of e)p.complexity>0&&m++;r.complexity=I("filesWithComplexity",m,R.complexity,v.complexity,p=>p==="inconclusive"?`${m} files with measurable complexity \u2014 not enough to rank.`:`${m} files with measurable complexity (${p.toUpperCase()} sample size).`);let s=0;for(let p of o.values())p>=3&&s++;r.nesting=I("filesWithNesting>=3",s,R.nesting,v.nesting,p=>p==="inconclusive"?`${s} files with nesting depth \u2265 3 \u2014 not enough to rank.`:`${s} files with nesting depth \u2265 3 (${p.toUpperCase()} sample size).`);let u=[...n.values()].reduce((p,C)=>p+C,0),f=n.size,h=u<_||f<j;r.defects=I("fixCommits",u,R.defects,v.defects,p=>p==="inconclusive"||h?`${u} fix: commits across ${f} files \u2014 need \u2265 ${_} commits across \u2265 ${j} files (matches code-maat's --min-revs default).`:`${u} fix: commits across ${f} files (${p.toUpperCase()} sample size).`),h&&(r.defects={...r.defects,level:"inconclusive"},l.defects={reason:`insufficient data (${u} fix: commits across ${f} files, need ${_}+ commits across ${j}+ files)`,suggestion:"Adopt conventional commits with fix: prefix. See conventionalcommits.org",confidence:r.defects});let g=0;for(let p of i.values())p>g&&(g=p);r.authors=I("maxAuthors",g,R.authors,v.authors,p=>p==="inconclusive"?`${g} distinct authors on the most-touched file \u2014 not enough to rank ownership.`:`${g} distinct authors on the most-touched file (${p.toUpperCase()} sample size).`),g<=1&&(r.authors={...r.authors,level:"inconclusive"},l.authors={reason:"all files have the same author count \u2014 no variance to rank",confidence:r.authors});let b={};for(let p of W){if(l[p.key])continue;if(r[p.key].level==="inconclusive"){l[p.key]={reason:r[p.key].reason,confidence:r[p.key]};continue}let C=a[p.key],w=Se(e,t,C.extract,C.density);if(w.length===0)continue;let K=c>0?w.slice(0,c):w,J={hot:0,warm:0,cool:0};for(let A of w)J[A.tier]++;b[p.key]={label:p.label,scoreFormula:p.scoreFormula,totalScore:w.reduce((A,be)=>A+be.score,0),tierCounts:J,totalEntries:w.length,showing:K.length,entries:K,confidence:r[p.key]}}return{rankings:b,skipped:l}}function B(){let e;try{e=E("git ls-files",{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let t=new Set;for(let n of e.toString().split(`
9
- `)){let o=T(n.trim());o&&t.add(o)}return t}function se(e,t,n,o,i){let c=[];for(let[r,m]of e){if(m<o)continue;let[s,u]=r.split("\0"),f=t.get(s)??0,h=t.get(u)??0,g=Math.min(f,h),b=g>0?Math.round(m/g*1e3)/10:0,p=(n.get(s)??0)+(n.get(u)??0),C={file1:s,file2:u,cochanges:m,degree:b,totalComplexity:p,couplingScore:m,percentOfTotal:0,tier:"cool"},w=Math.max(f,h);m>0&&w>0&&m/w>=.9&&(C.lockstep=!0),i&&(i.has(s)||(C.file1Deleted=!0),i.has(u)||(C.file2Deleted=!0)),c.push(C)}c.sort((r,m)=>m.couplingScore-r.couplingScore);let a=c.reduce((r,m)=>r+m.couplingScore,0);if(a===0)return[];let l=c.map(r=>({...r,score:r.couplingScore}));U(l,a);for(let r=0;r<c.length;r++)c[r].percentOfTotal=l[r].percentOfTotal,c[r].tier=l[r].tier;return c}function ce(e){let t=new Map;for(let n of e){let o;try{o=Y(n,"utf-8")}catch{t.set(n,0);continue}let i=[],c=new Map,a=0;for(let s of o.split(`
10
- `)){if(!s.trim())continue;let u=s.match(/^(\s+)/);if(!u){a=0;continue}let f=u[1];if(i.push(f),f.includes(" "))continue;let h=f.length,g=h-a;g>0&&c.set(g,(c.get(g)??0)+1),a=h}let l=4,r=0;for(let[s,u]of c)(u>r||u===r&&s<l)&&(r=u,l=s);let m=0;for(let s of i){let u=0;for(let f of s)f===" "?u+=1:f===" "&&(u+=1/l);u=Math.floor(u),u>m&&(m=u)}t.set(n,m)}return t}var ve=[{dir:".github",pattern:".github/**",comment:"GitHub Actions and workflows"},{dir:".circleci",pattern:".circleci/**",comment:"CircleCI configuration"},{dir:".husky",pattern:".husky/**",comment:"Git hooks"},{dir:".vscode",pattern:".vscode/**",comment:"VS Code settings"},{dir:".idea",pattern:".idea/**",comment:"JetBrains settings"},{dir:"scripts",pattern:"scripts/**",comment:"Build and utility scripts"},{dir:"docs",pattern:"docs/**",comment:"Documentation"},{dir:"docker",pattern:"docker/**",comment:"Docker configuration"},{dir:"fixtures",pattern:"fixtures/**",comment:"Test fixtures"},{dir:"vendor",pattern:"vendor/**",comment:"Vendored dependencies"}],Re=[{test:/\.generated\./,pattern:"*.generated.*",comment:"Generated code"},{test:/\.gen\.[^.]+$/,pattern:"*.gen.*",comment:"Generated code"},{test:/\.config\.\w/,pattern:"*.config.*",comment:"Configuration files"},{test:/(?:^|\/)\.gitlab-ci/,pattern:".gitlab-ci*",comment:"GitLab CI configuration"},{test:/^\.claude\/commands\//,pattern:".claude/commands/**",comment:"Claude Code slash commands (often generated from sources)"},{test:/^\.opencode\/commands\//,pattern:".opencode/commands/**",comment:"OpenCode slash commands (often generated from sources)"},{test:/^\.cursor\/rules\//,pattern:".cursor/rules/**",comment:"Cursor rules (often generated from sources)"}];function ae(){let e=B(),t=[],n=new Set;for(let o of e){let i=o.indexOf("/");i>0&&n.add(o.slice(0,i))}for(let o of ve)n.has(o.dir)&&t.push({pattern:o.pattern,comment:o.comment});for(let o of Re)for(let i of e)if(o.test.test(i)){t.push({pattern:o.pattern,comment:o.comment});break}return t}function le(e,t=H){let n=["# Generated by obscene init","# Edit this file to customize which files are excluded from analysis.","# Patterns use glob syntax (same as .gitignore).","# See: https://github.com/wbern/obscene#ignore-files",""];for(let o of t){n.push(`# ${o.title}`);for(let i of o.patterns)n.push(i.pattern);n.push("")}if(e.length>0){n.push("# Project-specific patterns");for(let o of e)n.push(`# ${o.comment}`),n.push(o.pattern);n.push("")}return n.join(`
11
- `)}var Ee=10,X={inconclusive:0,weak:1,plausible:2,acceptable:3};function Oe(e){let t=Object.values(e).map(i=>i.confidence),n=t.length;if(n<2)return{level:"inconclusive",reason:`${n} input ranking \u2014 RRF requires \u2265 2 independent rankings.`,inputs:{metric:"inputRankings",value:n,thresholds:{weak:2,plausible:3,acceptable:4}},source:v.composite};let o="acceptable";for(let i of t)X[i.level]<X[o]&&(o=i.level);return{level:o,reason:`Composite inherits min-of-inputs across ${n} rankings (weakest: ${o.toUpperCase()}).`,inputs:{metric:"inputRankings",value:n,thresholds:{weak:2,plausible:3,acceptable:4}},source:v.composite}}function ue(e,t,n){let o=Object.keys(e).length,i=Oe(e),c=new Map;for(let s of Object.values(e))for(let u=0;u<s.entries.length;u++){let f=s.entries[u].file,h=1/(Ee+u+1),g=c.get(f);g?(g.score+=h,g.dims+=1):c.set(f,{score:h,dims:1})}let a=[];for(let[s,u]of c)a.push({file:s,score:Math.round(u.score*1e4)/1e4,percentOfTotal:0,tier:"cool",churn:t.get(s)??0,dimensionCount:u.dims});a.sort((s,u)=>u.score-s.score);let l=a.reduce((s,u)=>s+u.score,0);if(l===0)return{label:"Combined",scoreFormula:"reciprocal rank fusion across all dimensions",totalScore:0,tierCounts:{hot:0,warm:0,cool:0},totalDimensions:o,totalEntries:0,showing:0,entries:[],confidence:i};U(a,l);let r=n>0?a.slice(0,n):a,m={hot:0,warm:0,cool:0};for(let s of a)m[s.tier]++;return{label:"Combined",scoreFormula:"reciprocal rank fusion across all dimensions",totalScore:Math.round(l*1e4)/1e4,tierCounts:m,totalDimensions:o,totalEntries:a.length,showing:r.length,entries:r,confidence:i}}function pe(e){return I("commitsInWindow",e,R.coupling,v.coupling,t=>t==="inconclusive"?`${e} commits in window \u2014 need \u2265 ${R.coupling.weak} (matches code-maat's --min-revs default).`:`${e} commits in window (${t.toUpperCase()} sample size).`)}function me(e){try{let t=E(`git rev-list --count --since="${e} months ago" HEAD`,{stdio:["pipe","pipe","pipe"]});return parseInt(t.toString().trim(),10)||0}catch{throw new Error("Not a git repository or git is not installed.")}}import y from"picocolors";import k from"picocolors";var Ie=/\x1b\[[0-9;]*m/g;function Te(e){return e>=11904&&e<=12543||e>=12800&&e<=13311||e>=13312&&e<=40959||e>=44032&&e<=55215||e>=63744&&e<=64255||e>=65281&&e<=65376||e>=65504&&e<=65510||e>=9728&&e<=9983||e>=127744&&e<=129791||e>=131072&&e<=195103}function fe(e){let t=e.replace(Ie,""),n=0;for(let o of t){let i=o.codePointAt(0);i===65038||i===65039||(n+=Te(i)?2:1)}return n}function x(e,t){let n=fe(e);return n>=t?e:e+" ".repeat(t-n)}function d(e,t){let n=fe(e);return n>=t?e:" ".repeat(t-n)+e}function S(e,t){if(t<=0)return"";if(e.length<=t)return e;if(t===1)return"\u2026";let n=t-1,o=Math.ceil(n*.6),i=n-o;return`${e.slice(0,i)}\u2026${e.slice(e.length-o)}`}function M(e){return e==="hot"?k.red("\u{1F525} HOT "):e==="warm"?k.yellow("\u2600\uFE0F WARM"):k.blue("\u{1F9CA} COOL")}function D(e,t){return e==="hot"?k.red(t):e==="warm"?k.yellow(t):k.blue(t)}function F(e,t,n){let o=[];return o.push(`Tiers: ${k.red(`${e.hot} HOT`)}, ${k.yellow(`${e.warm} WARM`)}, ${k.blue(`${e.cool} COOL`)}`),o.push(`Showing: ${t} of ${n}`),o}var $e={inconclusive:y.gray,weak:y.yellow,plausible:y.cyan,acceptable:y.green};function G(e){let t=$e[e.level];return[t(`Confidence: ${e.level.toUpperCase()} \u2014 ${e.reason}`)]}var Me=Object.fromEntries(W.map(e=>[e.key,e.label]));function ge(e){let t=[],{summary:n,files:o}=e;t.push(`Complexity Report \u2014 ${n.fileCount} files, ${n.totalComplexity} total complexity`),t.push(`Showing: ${n.showing} | Avg complexity/file: ${n.avgComplexityPerFile}`),t.push(""),t.push(x("File",60)+d("Code",8)+d("Complexity",12)+d("Density",9)+d("Comments",10)),t.push("\u2500".repeat(99));for(let i of o)t.push(x(S(i.file,58),60)+d(String(i.code),8)+d(String(i.complexity),12)+d(i.complexityDensity.toFixed(2),9)+d(String(i.comments),10));return t.push(""),t.push(y.dim("Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines")),t.push(y.dim("High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
12
- `)}function De(e){let t=[{header:"File",width:50,align:"left",value:i=>S(i.file,48)},{header:"Score",width:8,align:"right",value:i=>i.score.toLocaleString()},{header:"%",width:7,align:"right",value:i=>i.percentOfTotal.toFixed(1)},{header:"Churn",width:7,align:"right",value:i=>String(i.churn)}],n={complexity:[{header:"Cmplx",width:7,align:"right",value:i=>String(i.metricValue)},{header:"Dens",width:7,align:"right",value:i=>(i.metricDensity??0).toFixed(2)}],nesting:[{header:"Nest",width:6,align:"right",value:i=>String(i.metricValue)}],defects:[{header:"Fixes",width:6,align:"right",value:i=>String(i.metricValue)},{header:"FxDns",width:7,align:"right",value:i=>(i.metricDensity??0).toFixed(4)}],authors:[{header:"Auth",width:6,align:"right",value:i=>String(i.metricValue)}]},o={header:"Tier",width:12,align:"right",value:i=>M(i.tier)};return[...t,...n[e]??[],o]}var Fe={complexity:"\u{1F9EC}",nesting:"\u{1F4CF}",defects:"\u{1F527}",authors:"\u{1F465}"};function Le(e,t,n){let o=[],i=De(e),c=Fe[e],a=c?`${c} `:"",l=t.label.toUpperCase().replace("CHURN","\u{1F504} CHURN");if(o.push(`${a}${l} \u2014 Total score: ${t.totalScore.toLocaleString()}`),o.push(...G(t.confidence)),n)for(let s of n.split(`
13
- `))o.push(y.dim(s));o.push(...F(t.tierCounts,t.showing,t.totalEntries)),o.push("");let r=i.map(s=>s.align==="left"?x(s.header,s.width):d(s.header,s.width)).join("");o.push(r);let m=i.reduce((s,u)=>s+u.width,0);o.push("\u2500".repeat(m));for(let s of t.entries){let f=i.map(h=>{let g=h.value(s);return h.align==="left"?x(g,h.width):d(g,h.width)}).join("");o.push(D(s.tier,f))}return o}function de(e){let t=[],{churnWindow:n,rankings:o,corpus:i}=e;t.push(`Hotspots \u2014 ${n} churn window`),i&&i.fileCount>0&&i.totalComplexity===0&&(t.push(""),t.push(y.yellow("Note: no measurable code complexity detected across this corpus (cyclomatic = 0).")),t.push(y.yellow("Rankings reflect size and churn only \u2014 HOT/WARM/COOL are relative groupings, not risk labels."))),t.push("");let c=Object.keys(o);for(let l=0;l<c.length;l++){let r=c[l];t.push(...Le(r,o[r],e.guide[r])),l<c.length-1&&(t.push(""),t.push("\xB7 \xB7 \xB7"),t.push(""))}if(e.skipped)for(let[l,r]of Object.entries(e.skipped)){t.push("");let m=Me[l]??`${l.charAt(0).toUpperCase()+l.slice(1)} \xD7 Churn`;t.push(`${m} \u2014 skipped (${r.reason})`),r.suggestion&&t.push(` ${r.suggestion}`)}t.push(""),t.push(y.dim("Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."));let a=i!==void 0&&i.fileCount>0&&i.totalComplexity===0;return t.push(y.dim(a?"High scores flag files that change often and are sizable \u2014 neither is bad in itself.":"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
14
- `)}function he(e){let t=[],{tierCounts:n,totalScore:o,churnWindow:i,couplings:c}=e;t.push(`Coupling \u2014 ${i} churn window | Min shared: ${e.minCochanges} | Total score: ${o.toLocaleString()}`),t.push(...G(e.confidence)),t.push(...F(n,e.showing,e.totalCouplings)),t.push(x("File 1",35)+x("File 2",35)+d("Shared",7)+d("Degree",8)+d("Cmplx",7)+d("Tier",12)),t.push("\u2500".repeat(104));let a=!1,l=!1;for(let r of c){(r.file1Deleted||r.file2Deleted)&&(a=!0),r.lockstep&&(l=!0);let m=r.file1Deleted?`\u2020 ${S(r.file1,31)}`:S(r.file1,33),s=r.file2Deleted?`\u2020 ${S(r.file2,31)}`:S(r.file2,33),u=r.lockstep?`${r.degree.toFixed(1)}\u21C4`:`${r.degree.toFixed(1)}%`,f=x(m,35)+x(s,35)+d(String(r.cochanges),7)+d(u,8)+d(String(r.totalComplexity),7)+d(M(r.tier),12);t.push(D(r.tier,f))}return t.push(""),t.push(y.dim("Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files")),a&&t.push(y.dim("\u2020 = file no longer present at HEAD (deleted or renamed)")),l&&t.push(y.dim("\u21C4 = lockstep pair (both files only ever changed together \u2014 signal is real but uninformative)")),t.push(y.dim("Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine.")),t.push(y.dim("Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
15
- `)}function ye(e){let t=[];t.push("\u2550".repeat(84)),t.push(`\u2605 ${e.label.toUpperCase()} \u2014 Total score: ${e.totalScore.toLocaleString()}`),t.push(...G(e.confidence)),t.push(...F(e.tierCounts,e.showing,e.totalEntries)),t.push(""),t.push(x("File",50)+d("Score",9)+d("Churn",7)+d("Dims",6)+d("Tier",12)),t.push("\u2500".repeat(84));for(let n of e.entries){let o=x(S(n.file,48),50)+d(n.score.toFixed(4),9)+d(String(n.churn),7)+d(`${n.dimensionCount}/${e.totalDimensions}`,6)+d(M(n.tier),12);t.push(D(n.tier,o))}return t.join(`
16
- `)}var O=new Ae;O.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("2.2.2");var _e={complexity:"Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",complexityDensity:"Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",comments:"Comment line count. Low comments in high-density files may indicate under-documented logic. High comments alone is not a problem."},je={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.",complexity:`complexity \xD7 churn. Complex code that changes often poses maintenance risk.
2
+ import{existsSync as L,writeFileSync as We}from"fs";import{Command as Ge}from"commander";import{execSync as E}from"child_process";import{readFileSync as Q}from"fs";var Se=[".obsignore",".obsceneignore"];function Z(){for(let e of Se)try{return Q(e,"utf-8").split(`
3
+ `).map(n=>n.trim()).filter(n=>n!==""&&!n.startsWith("#"))}catch(t){if(t&&typeof t=="object"&&"code"in t&&t.code==="ENOENT")continue;throw t}return[]}var U=[{title:"Test files and test infrastructure",patterns:[{pattern:"*.test.*",comment:"Unit test files"},{pattern:"*.spec.*",comment:"Spec test files"},{pattern:"*.integration.test.*",comment:"Integration tests"},{pattern:"test-setup.*",comment:"Test setup files"},{pattern:"test-utils.*",comment:"Test utility files"},{pattern:"test-helpers.*",comment:"Test helper files"},{pattern:"__tests__/**",comment:"Test directories"},{pattern:"__mocks__/**",comment:"Mock directories"},{pattern:"*.stories.*",comment:"Storybook stories"},{pattern:"*.d.ts",comment:"TypeScript declaration files"}]},{title:"Lock files and package manifests",patterns:[{pattern:"package.json",comment:"npm package manifest"},{pattern:"package-lock.json",comment:"npm lock file"},{pattern:"pnpm-lock.yaml",comment:"pnpm lock file"},{pattern:"yarn.lock",comment:"Yarn lock file"},{pattern:"bun.lock",comment:"Bun lock file"}]}],ke=.5,ve=.8,H=5,j=3,I={complexity:{weak:3,plausible:10,acceptable:30},nesting:{weak:3,plausible:10,acceptable:30},defects:{weak:5,plausible:15,acceptable:50},authors:{weak:2,plausible:4,acceptable:8},coupling:{weak:5,plausible:30,acceptable:100}},R={complexity:"Engineering judgment: any rank ordering needs \u2265 3 items to be meaningful; higher tiers scale from there. No paper prescribes these exact cutoffs.",nesting:"Engineering judgment, informed by Campbell (SonarSource 2018) Cognitive Complexity which assigns a compounding penalty per nesting level. The 3/10/30 sample-count tiers are not from the paper.",defects:"code-maat's --min-revs default of 5 (Adam Tornhill); higher tiers are engineering judgment. Gall et al. (IWPSE 2003) and Hassan (ICSE 2009) study co-change and change-entropy but do not prescribe a specific commit-count floor.",authors:"Engineering judgment. Bird et al. (FSE 2011) Don't Touch My Code! shows minor contributors (< 5% of commits) correlate with elevated defects, motivating attention to contributor count \u2014 but the 2/4/8 tiers here are not from the paper.",coupling:"code-maat defaults (--min-revs 5, --max-changeset-size 30, Adam Tornhill). CodeScene's documented temporal-coupling default filters files with fewer than 10 commits. The 30/100 upper tiers are engineering judgment.",composite:"Reciprocal Rank Fusion (Cormack et al., SIGIR 2009) fuses multiple independent rankings; min-of-inputs is a strict monotone aggregator \u2014 when every input ranking is at confidence level L, the composite cannot exceed L."};function M(e,t,n,i,o){let s;return t<n.weak?s="inconclusive":t<n.plausible?s="weak":t<n.acceptable?s="plausible":s="acceptable",{level:s,reason:o(s),inputs:{metric:e,value:t,thresholds:n},source:i}}function ee(e,t){return t.some(n=>n.test(e))}function te(e){let t=e.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*\*/g,"\u27E8GLOBSTAR\u27E9").replace(/\*/g,"[^/]*").replace(/⟨GLOBSTAR⟩/g,".*").replace(/\?/g,".");return new RegExp(t)}function $(e){let t=e.replaceAll("\\","/");return t.startsWith("./")?t.slice(2):t}function D(e=[]){let t=e.map(te),n;try{n=E("scc --by-file --format json --no-cocomo --no-gen",{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch(s){throw s&&typeof s=="object"&&"code"in s&&s.code==="ENOENT"?new Error("scc not found. Install it: https://github.com/boyter/scc#install"):s}let i=JSON.parse(n.toString()),o=[];for(let s of i)for(let c of s.Files){let m=$(c.Location);ee(m,t)||o.push({file:m,code:c.Code,lines:c.Lines,complexity:c.Complexity,comments:c.Comment,complexityDensity:c.Code>0?Math.round(c.Complexity/c.Code*100)/100:0})}return o.sort((s,c)=>c.complexity-s.complexity)}function ne(e,t){let n;try{n=E(e,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error(t)}let i=new Map;for(let o of n.toString().split(`
4
+ `)){let s=$(o.trim());s&&i.set(s,(i.get(s)??0)+1)}return i}function P(e){return ne(`git log --since="${e} months ago" --format="" --name-only`,"Not a git repository or git is not installed.")}function oe(e){return ne(`git log --since="${e} months ago" --grep="^fix" --format="" --name-only`,"Not a git repository or git is not installed.")}function Re(e){let t=new Set,n=[];for(let i of e.split(" ")){let o=i.trim();if(!o)continue;let s=o.match(/^(.+?)\s*<[^>]+>\s*$/),c=(s?s[1]:o).trim();!c||c.endsWith("[bot]")||t.has(c)||(t.add(c),n.push(c))}return n}function ie(e){let t;try{t=E(`git log --since="${e} months ago" --format="COMMIT_SEP%n%aN%x09%(trailers:key=Co-authored-by,valueonly,separator=%x09)" --name-only`,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let n=new Map,i=t.toString().split(`COMMIT_SEP
5
+ `);for(let o of i){if(!o.trim())continue;let s=o.split(`
6
+ `),c=Re(s[0]);if(c.length!==0)for(let m=1;m<s.length;m++){let r=$(s[m].trim());if(!r)continue;let l=n.get(r);l||(l=new Map,n.set(r,l));for(let a of c)l.set(a,(l.get(a)??0)+1)}}return n}var Ee=20;function re(e,t=[]){let n=t.map(te),i;try{i=E(`git log --since="${e} months ago" --format="COMMIT_SEP%n" --name-only`,{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let o=new Map,s=i.toString().split(`COMMIT_SEP
7
+ `);for(let c of s){if(!c.trim())continue;let m=new Set;for(let l of c.split(`
8
+ `)){let a=$(l.trim());a&&(ee(a,n)||m.add(a))}let r=[...m];if(!(r.length<2||r.length>Ee))for(let l=0;l<r.length;l++)for(let a=l+1;a<r.length;a++){let[p,f]=r[l]<r[a]?[r[l],r[a]]:[r[a],r[l]],g=p.includes("/")?p.slice(0,p.lastIndexOf("/")):"",h=f.includes("/")?f.slice(0,f.lastIndexOf("/")):"";if(g===h)continue;let b=`${p}\0${f}`;o.set(b,(o.get(b)??0)+1)}}return o}function B(e,t){let n=0;for(let i of e){i.percentOfTotal=Math.round(i.score/t*1e3)/10,n+=i.score;let o=n/t;o<=ke?i.tier="hot":o<=ve?i.tier="warm":i.tier="cool"}}var W=[{key:"complexity",label:"Complexity \xD7 Churn",scoreFormula:"complexity \xD7 churn"},{key:"nesting",label:"Nesting \xD7 Churn",scoreFormula:"maxNesting \xD7 churn"},{key:"defects",label:"Fix Activity \xD7 Churn",scoreFormula:"fixes \xD7 churn"},{key:"authors",label:"Authors \xD7 Churn",scoreFormula:"authors \xD7 churn"}];function Oe(e,t,n,i){let o=e.map(c=>{let m=t.get(c.file)??0,r=n(c);return{file:c.file,score:r*m,percentOfTotal:0,tier:"cool",churn:m,metricValue:r,metricDensity:i?i(c):void 0}}).filter(c=>c.score>0).sort((c,m)=>m.score-c.score),s=o.reduce((c,m)=>c+m.score,0);return s===0?[]:(B(o,s),o)}var Ie=.05,Te=2;function Me(e){if(!e||e.size===0)return null;let t=0;for(let o of e.values())t+=o;if(t<Te)return null;let n=t*Ie,i=0;for(let o of e.values())o<n&&i++;return i}function se(e,t,n,i,o,s,c){let m={complexity:{extract:u=>u.complexity,density:u=>u.complexityDensity},nesting:{extract:u=>u.complexity===0?0:i.get(u.file)??0},defects:{extract:u=>n.get(u.file)??0,density:u=>{let x=n.get(u.file)??0;return u.code>0?Math.round(x/u.code*1e4)/1e4:0}},authors:{extract:u=>o.get(u.file)??0}},r={},l={},a=0;for(let u of e)u.complexity>0&&a++;l.complexity=M("filesWithComplexity",a,I.complexity,R.complexity,u=>u==="inconclusive"?`${a} files with measurable complexity \u2014 not enough to rank.`:`${a} files with measurable complexity (${u.toUpperCase()} sample size).`);let p=0;for(let u of e)u.complexity>0&&(i.get(u.file)??0)>=3&&p++;l.nesting=M("filesWithNesting>=3",p,I.nesting,R.nesting,u=>u==="inconclusive"?`${p} files with nesting depth \u2265 3 \u2014 not enough to rank.`:`${p} files with nesting depth \u2265 3 (${u.toUpperCase()} sample size).`);let f=[...n.values()].reduce((u,x)=>u+x,0),g=n.size,h=f<H||g<j;l.defects=M("fixCommits",f,I.defects,R.defects,u=>u==="inconclusive"||h?`${f} fix: commits across ${g} files \u2014 need \u2265 ${H} commits across \u2265 ${j} files (matches code-maat's --min-revs default).`:`${f} fix: commits across ${g} files (${u.toUpperCase()} sample size).`),h&&(l.defects={...l.defects,level:"inconclusive"},r.defects={reason:`insufficient data (${f} fix: commits across ${g} files, need ${H}+ commits across ${j}+ files)`,suggestion:"Adopt conventional commits with fix: prefix. See conventionalcommits.org",confidence:l.defects});let b=0;for(let u of o.values())u>b&&(b=u);l.authors=M("maxAuthors",b,I.authors,R.authors,u=>u==="inconclusive"?`${b} distinct authors on the most-touched file \u2014 not enough to rank ownership.`:`${b} distinct authors on the most-touched file (${u.toUpperCase()} sample size).`),b<=1&&(l.authors={...l.authors,level:"inconclusive"},r.authors={reason:"all files have the same author count \u2014 no variance to rank",confidence:l.authors});let C={};for(let u of W){if(r[u.key])continue;if(l[u.key].level==="inconclusive"){r[u.key]={reason:l[u.key].reason,confidence:l[u.key]};continue}let x=m[u.key],v=Oe(e,t,x.extract,x.density);if(v.length===0)continue;if(u.key==="authors"&&c)for(let O of v)O.minorAuthors=Me(c.get(O.file));let J=s>0?v.slice(0,s):v,Y={hot:0,warm:0,cool:0};for(let O of v)Y[O.tier]++;C[u.key]={label:u.label,scoreFormula:u.scoreFormula,totalScore:v.reduce((O,xe)=>O+xe.score,0),tierCounts:Y,totalEntries:v.length,showing:J.length,entries:J,confidence:l[u.key]}}return{rankings:C,skipped:r}}function G(){let e;try{e=E("git ls-files",{maxBuffer:50*1024*1024,stdio:["pipe","pipe","pipe"]})}catch{throw new Error("Not a git repository or git is not installed.")}let t=new Set;for(let n of e.toString().split(`
9
+ `)){let i=$(n.trim());i&&t.add(i)}return t}function ce(e,t,n,i,o){let s=[];for(let[r,l]of e){if(l<i)continue;let[a,p]=r.split("\0"),f=t.get(a)??0,g=t.get(p)??0,h=Math.min(f,g),b=h>0?Math.round(l/h*1e3)/10:0,C=(n.get(a)??0)+(n.get(p)??0),u={file1:a,file2:p,cochanges:l,degree:b,totalComplexity:C,couplingScore:l,percentOfTotal:0,tier:"cool"},x=Math.max(f,g);l>0&&x>0&&l/x>=.9&&(u.lockstep=!0),o&&(o.has(a)||(u.file1Deleted=!0),o.has(p)||(u.file2Deleted=!0)),s.push(u)}s.sort((r,l)=>l.couplingScore-r.couplingScore);let c=s.reduce((r,l)=>r+l.couplingScore,0);if(c===0)return[];let m=s.map(r=>({...r,score:r.couplingScore}));B(m,c);for(let r=0;r<s.length;r++)s[r].percentOfTotal=m[r].percentOfTotal,s[r].tier=m[r].tier;return s}function ae(e){let t=new Map;for(let n of e){let i;try{i=Q(n,"utf-8")}catch{t.set(n,0);continue}let o=[],s=new Map,c=0;for(let a of i.split(`
10
+ `)){if(!a.trim())continue;let p=a.match(/^(\s+)/);if(!p){c=0;continue}let f=p[1];if(o.push(f),f.includes(" "))continue;let g=f.length,h=g-c;h>0&&s.set(h,(s.get(h)??0)+1),c=g}let m=4,r=0;for(let[a,p]of s)(p>r||p===r&&a<m)&&(r=p,m=a);let l=0;for(let a of o){let p=0;for(let f of a)f===" "?p+=1:f===" "&&(p+=1/m);p=Math.floor(p),p>l&&(l=p)}t.set(n,l)}return t}var $e=[{dir:".github",pattern:".github/**",comment:"GitHub Actions and workflows"},{dir:".circleci",pattern:".circleci/**",comment:"CircleCI configuration"},{dir:".husky",pattern:".husky/**",comment:"Git hooks"},{dir:".vscode",pattern:".vscode/**",comment:"VS Code settings"},{dir:".idea",pattern:".idea/**",comment:"JetBrains settings"},{dir:"scripts",pattern:"scripts/**",comment:"Build and utility scripts"},{dir:"docs",pattern:"docs/**",comment:"Documentation"},{dir:"docker",pattern:"docker/**",comment:"Docker configuration"},{dir:"fixtures",pattern:"fixtures/**",comment:"Test fixtures"},{dir:"vendor",pattern:"vendor/**",comment:"Vendored dependencies"}],De=[{test:/\.generated\./,pattern:"*.generated.*",comment:"Generated code"},{test:/\.gen\.[^.]+$/,pattern:"*.gen.*",comment:"Generated code"},{test:/\.config\.\w/,pattern:"*.config.*",comment:"Configuration files"},{test:/(?:^|\/)\.gitlab-ci/,pattern:".gitlab-ci*",comment:"GitLab CI configuration"},{test:/^\.claude\/commands\//,pattern:".claude/commands/**",comment:"Claude Code slash commands (often generated from sources)"},{test:/^\.opencode\/commands\//,pattern:".opencode/commands/**",comment:"OpenCode slash commands (often generated from sources)"},{test:/^\.cursor\/rules\//,pattern:".cursor/rules/**",comment:"Cursor rules (often generated from sources)"}];function le(){let e=G(),t=[],n=new Set;for(let i of e){let o=i.indexOf("/");o>0&&n.add(i.slice(0,o))}for(let i of $e)n.has(i.dir)&&t.push({pattern:i.pattern,comment:i.comment});for(let i of De)for(let o of e)if(i.test.test(o)){t.push({pattern:i.pattern,comment:i.comment});break}return t}function ue(e,t=U){let n=["# Generated by obscene init","# Edit this file to customize which files are excluded from analysis.","# Patterns use glob syntax (same as .gitignore).","# See: https://github.com/wbern/obscene#ignore-files",""];for(let i of t){n.push(`# ${i.title}`);for(let o of i.patterns)n.push(o.pattern);n.push("")}if(e.length>0){n.push("# Project-specific patterns");for(let i of e)n.push(`# ${i.comment}`),n.push(i.pattern);n.push("")}return n.join(`
11
+ `)}var Fe=10,X={inconclusive:0,weak:1,plausible:2,acceptable:3};function Ne(e){let t=Object.values(e).map(o=>o.confidence),n=t.length;if(n<2)return{level:"inconclusive",reason:`${n} input ranking \u2014 RRF requires \u2265 2 independent rankings.`,inputs:{metric:"inputRankings",value:n,thresholds:{weak:2,plausible:3,acceptable:4}},source:R.composite};let i="acceptable";for(let o of t)X[o.level]<X[i]&&(i=o.level);return{level:i,reason:`Composite inherits min-of-inputs across ${n} rankings (weakest: ${i.toUpperCase()}).`,inputs:{metric:"inputRankings",value:n,thresholds:{weak:2,plausible:3,acceptable:4}},source:R.composite}}function pe(e,t,n){let i=Object.keys(e).length,o=Ne(e),s=new Map;for(let a of Object.values(e))for(let p=0;p<a.entries.length;p++){let f=a.entries[p].file,g=1/(Fe+p+1),h=s.get(f);h?(h.score+=g,h.dims+=1):s.set(f,{score:g,dims:1})}let c=[];for(let[a,p]of s)c.push({file:a,score:Math.round(p.score*1e4)/1e4,percentOfTotal:0,tier:"cool",churn:t.get(a)??0,dimensionCount:p.dims});c.sort((a,p)=>p.score-a.score);let m=c.reduce((a,p)=>a+p.score,0);if(m===0)return{label:"Combined",scoreFormula:"reciprocal rank fusion across all dimensions",totalScore:0,tierCounts:{hot:0,warm:0,cool:0},totalDimensions:i,totalEntries:0,showing:0,entries:[],confidence:o};B(c,m);let r=n>0?c.slice(0,n):c,l={hot:0,warm:0,cool:0};for(let a of c)l[a.tier]++;return{label:"Combined",scoreFormula:"reciprocal rank fusion across all dimensions",totalScore:Math.round(m*1e4)/1e4,tierCounts:l,totalDimensions:i,totalEntries:c.length,showing:r.length,entries:r,confidence:o}}function me(e){return M("commitsInWindow",e,I.coupling,R.coupling,t=>t==="inconclusive"?`${e} commits in window \u2014 need \u2265 ${I.coupling.weak} (matches code-maat's --min-revs default).`:`${e} commits in window (${t.toUpperCase()} sample size).`)}function fe(e){try{let t=E(`git rev-list --count --since="${e} months ago" HEAD`,{stdio:["pipe","pipe","pipe"]});return parseInt(t.toString().trim(),10)||0}catch{throw new Error("Not a git repository or git is not installed.")}}var Ae=30;function ge(e){let t=e*Ae,n;try{let c=E("git log --format=%ct --reverse HEAD",{maxBuffer:52428800,stdio:["pipe","pipe","pipe"]}).toString().split(`
12
+ `,1)[0].trim();if(n=parseInt(c,10),!Number.isFinite(n)||n<=0)return{windowDays:t,spanDays:0,underCovered:!0}}catch{throw new Error("Not a git repository or git is not installed.")}let i=Math.floor(Date.now()/1e3),o=Math.max(0,Math.floor((i-n)/86400));return{windowDays:t,spanDays:o,underCovered:o<t}}import y from"picocolors";import S from"picocolors";var Le=/\x1b\[[0-9;]*m/g;function _e(e){return e>=11904&&e<=12543||e>=12800&&e<=13311||e>=13312&&e<=40959||e>=44032&&e<=55215||e>=63744&&e<=64255||e>=65281&&e<=65376||e>=65504&&e<=65510||e>=9728&&e<=9983||e>=127744&&e<=129791||e>=131072&&e<=195103}function de(e){let t=e.replace(Le,""),n=0;for(let i of t){let o=i.codePointAt(0);o===65038||o===65039||(n+=_e(o)?2:1)}return n}function w(e,t){let n=de(e);return n>=t?e:e+" ".repeat(t-n)}function d(e,t){let n=de(e);return n>=t?e:" ".repeat(t-n)+e}function k(e,t){if(t<=0)return"";if(e.length<=t)return e;if(t===1)return"\u2026";let n=t-1,i=Math.ceil(n*.6),o=n-i;return`${e.slice(0,o)}\u2026${e.slice(e.length-i)}`}function F(e){return e==="hot"?S.red("\u{1F525} HOT "):e==="warm"?S.yellow("\u2600\uFE0F WARM"):S.blue("\u{1F9CA} COOL")}function N(e,t){return e==="hot"?S.red(t):e==="warm"?S.yellow(t):S.blue(t)}function A(e,t,n){let i=[];return i.push(`Tiers: ${S.red(`${e.hot} HOT`)}, ${S.yellow(`${e.warm} WARM`)}, ${S.blue(`${e.cool} COOL`)}`),i.push(`Showing: ${t} of ${n}`),i}var He={inconclusive:y.gray,weak:y.yellow,plausible:y.cyan,acceptable:y.green};function z(e){let t=He[e.level];return[t(`Confidence: ${e.level.toUpperCase()} \u2014 ${e.reason}`)]}var je=Object.fromEntries(W.map(e=>[e.key,e.label]));function he(e){let t=[],{summary:n,files:i}=e;t.push(`Complexity Report \u2014 ${n.fileCount} files, ${n.totalComplexity} total complexity`),t.push(`Showing: ${n.showing} | Avg complexity/file: ${n.avgComplexityPerFile}`),t.push(""),t.push(w("File",60)+d("Code",8)+d("Complexity",12)+d("Density",9)+d("Comments",10)),t.push("\u2500".repeat(99));for(let o of i)t.push(w(k(o.file,58),60)+d(String(o.code),8)+d(String(o.complexity),12)+d(o.complexityDensity.toFixed(2),9)+d(String(o.comments),10));return t.push(""),t.push(y.dim("Complexity=cyclomatic branch/loop count | Density=complexity/code | Comments=comment lines")),t.push(y.dim("High complexity is expected for parsers, state machines, and business logic. Compare density across files, not raw values.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
13
+ `)}function Ue(e){let t=[{header:"File",width:50,align:"left",value:o=>k(o.file,48)},{header:"Score",width:8,align:"right",value:o=>o.score.toLocaleString()},{header:"%",width:7,align:"right",value:o=>o.percentOfTotal.toFixed(1)},{header:"Churn",width:7,align:"right",value:o=>String(o.churn)}],n={complexity:[{header:"Cmplx",width:7,align:"right",value:o=>String(o.metricValue)},{header:"Dens",width:7,align:"right",value:o=>(o.metricDensity??0).toFixed(2)}],nesting:[{header:"Nest",width:6,align:"right",value:o=>String(o.metricValue)}],defects:[{header:"Fixes",width:6,align:"right",value:o=>String(o.metricValue)},{header:"FxDns",width:7,align:"right",value:o=>(o.metricDensity??0).toFixed(4)}],authors:[{header:"Auth",width:6,align:"right",value:o=>String(o.metricValue)},{header:"MinAuth",width:9,align:"right",value:o=>o.minorAuthors===null||o.minorAuthors===void 0?"\u2014":String(o.minorAuthors)}]},i={header:"Tier",width:12,align:"right",value:o=>F(o.tier)};return[...t,...n[e]??[],i]}var Pe={complexity:"\u{1F9EC}",nesting:"\u{1F4CF}",defects:"\u{1F527}",authors:"\u{1F465}"};function Be(e,t,n){let i=[],o=Ue(e),s=Pe[e],c=s?`${s} `:"",m=t.label.toUpperCase().replace("CHURN","\u{1F504} CHURN");if(i.push(`${c}${m} \u2014 Total score: ${t.totalScore.toLocaleString()}`),i.push(...z(t.confidence)),n)for(let a of n.split(`
14
+ `))i.push(y.dim(a));i.push(...A(t.tierCounts,t.showing,t.totalEntries)),i.push("");let r=o.map(a=>a.align==="left"?w(a.header,a.width):d(a.header,a.width)).join("");i.push(r);let l=o.reduce((a,p)=>a+p.width,0);i.push("\u2500".repeat(l));for(let a of t.entries){let f=o.map(g=>{let h=g.value(a);return g.align==="left"?w(h,g.width):d(h,g.width)}).join("");i.push(N(a.tier,f))}return i}function ye(e){let t=[],{churnWindow:n,rankings:i,corpus:o}=e;t.push(`Hotspots \u2014 ${n} churn window`),o&&o.fileCount>0&&o.totalComplexity===0&&(t.push(""),t.push(y.yellow("Note: no measurable code complexity detected across this corpus (cyclomatic = 0).")),t.push(y.yellow("Rankings reflect size and churn only \u2014 HOT/WARM/COOL are relative groupings, not risk labels."))),t.push("");let s=Object.keys(i);for(let m=0;m<s.length;m++){let r=s[m];t.push(...Be(r,i[r],e.guide[r])),m<s.length-1&&(t.push(""),t.push("\xB7 \xB7 \xB7"),t.push(""))}if(e.skipped)for(let[m,r]of Object.entries(e.skipped)){t.push("");let l=je[m]??`${m.charAt(0).toUpperCase()+m.slice(1)} \xD7 Churn`;t.push(`${l} \u2014 skipped (${r.reason})`),r.suggestion&&t.push(` ${r.suggestion}`)}t.push(""),t.push(y.dim("Score=metric\xD7churn | Tiers are relative to THIS codebase, not absolute quality grades."));let c=o!==void 0&&o.fileCount>0&&o.totalComplexity===0;return t.push(y.dim(c?"High scores flag files that change often and are sizable \u2014 neither is bad in itself.":"High scores flag review candidates, not bad code \u2014 stable complex files (parsers, engines) score high naturally.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
15
+ `)}function be(e){let t=[],{tierCounts:n,totalScore:i,churnWindow:o,couplings:s}=e;t.push(`Coupling \u2014 ${o} churn window | Min shared: ${e.minCochanges} | Total score: ${i.toLocaleString()}`),t.push(...z(e.confidence)),t.push(...A(n,e.showing,e.totalCouplings)),t.push(w("File 1",35)+w("File 2",35)+d("Shared",7)+d("Degree",8)+d("Cmplx",7)+d("Tier",12)),t.push("\u2500".repeat(104));let c=!1,m=!1;for(let r of s){(r.file1Deleted||r.file2Deleted)&&(c=!0),r.lockstep&&(m=!0);let l=r.file1Deleted?`\u2020 ${k(r.file1,31)}`:k(r.file1,33),a=r.file2Deleted?`\u2020 ${k(r.file2,31)}`:k(r.file2,33),p=r.lockstep?`${r.degree.toFixed(1)}\u21C4`:`${r.degree.toFixed(1)}%`,f=w(l,35)+w(a,35)+d(String(r.cochanges),7)+d(p,8)+d(String(r.totalComplexity),7)+d(F(r.tier),12);t.push(N(r.tier,f))}return t.push(""),t.push(y.dim("Shared=co-changed commits | Degree=shared/min(churn)\xD7100 | Cmplx=sum of both files")),c&&t.push(y.dim("\u2020 = file no longer present at HEAD (deleted or renamed)")),m&&t.push(y.dim("\u21C4 = lockstep pair (both files only ever changed together \u2014 signal is real but uninformative)")),t.push(y.dim("Tiers are relative to THIS codebase, not absolute quality grades. High coupling may be intentional and fine.")),t.push(y.dim("Same-directory pairs excluded. Commits touching >20 files skipped. Only cross-directory dependencies shown.")),t.push(y.dim("Docs: https://github.com/wbern/obscene#metrics")),t.join(`
16
+ `)}function Ce(e){let t=[];t.push("\u2550".repeat(84)),t.push(`\u2605 ${e.label.toUpperCase()} \u2014 Total score: ${e.totalScore.toLocaleString()}`),t.push(...z(e.confidence)),t.push(...A(e.tierCounts,e.showing,e.totalEntries)),t.push(""),t.push(w("File",50)+d("Score",9)+d("Churn",7)+d("Dims",6)+d("Tier",12)),t.push("\u2500".repeat(84));for(let n of e.entries){let i=w(k(n.file,48),50)+d(n.score.toFixed(4),9)+d(String(n.churn),7)+d(`${n.dimensionCount}/${e.totalDimensions}`,6)+d(F(n.tier),12);t.push(N(n.tier,i))}return t.join(`
17
+ `)}var T=new Ge;T.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("2.4.0");var ze={complexity:"Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",complexityDensity:"Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",comments:"Comment line count. Low comments in high-density files may indicate under-documented logic. High comments alone is not a problem."},Ve={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.",complexity:`complexity \xD7 churn. Complex code that changes often poses maintenance risk.
17
18
  Metric concept: McCabe cyclomatic complexity (1976) via scc \xB7 Strength: objective, language-agnostic \xB7 Limit: parsers and state machines score high naturally`,nesting:`maxNesting \xD7 churn. Deeply nested code that changes often is harder to reason about.
18
19
  Metric concept: cognitive complexity research (SonarSource, G. Ann Campbell 2018) \xB7 Strength: catches hard-to-follow control flow \xB7 Limit: some patterns (error chains, config) legitimately nest deep`,defects:`fixes \xD7 churn. Count of fix: commits touching the file \xD7 churn. High values can mean latent fragility, but they also flag features that got debugged thoroughly \u2014 read the fix-commit history before concluding which.
19
- Metric concept: change-history metrics (Moser, Pedrycz & Succi 2008) via conventional commits (fix: prefix) \xB7 Strength: direct fix-history signal \xB7 Limit: counts fix activity, not defects per se; requires consistent fix: convention`,authors:`authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership.
20
- Metric concept: code ownership research (Bird et al. 2011, Microsoft) \xB7 Strength: flags diffuse ownership risk \xB7 Limit: doesn't measure expertise depth, bot authors filtered automatically`,composite:`Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.
21
- Metric concept: RRF (Cormack et al. 2009) \xB7 Strength: robust to outliers, no normalization needed \xB7 Limit: equal weight across all dimensions`,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.",corpus:"Aggregate stats for the analyzed file set (post-exclude \u2014 files filtered by .obsignore or --exclude are not counted). When totalComplexity is 0, the rankings reflect size and churn only; HOT/WARM/COOL become relative groupings rather than risk labels.",confidence:"Epistemic stamp on each ranking \u2014 INCONCLUSIVE / WEAK / PLAUSIBLE / ACCEPTABLE. These are engineering-judgment sample-size tiers, with the weak floor for defects matching code-maat's --min-revs default of 5. ACCEPTABLE is the ceiling \u2014 the tool never claims certainty about code quality, only that the sample supports the ranking. INCONCLUSIVE rankings are surfaced under skipped rather than ranked."},He={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.",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.",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.",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.",deleted:"file1Deleted / file2Deleted are set when the file is no longer present at HEAD (deleted or renamed away). The coupling signal is historical \u2014 the pair is not actionable in the current tree.",lockstep:"Set when shared commits / max(churn) \u2265 0.9 \u2014 both files almost always change together over the window. Typical of generator/mirror pairs (README \u2194 src/README, *.pb.go \u2194 *.proto). The coupling signal is real but uninformative; treat the pair as a single unit from git's perspective.",confidence:"Epistemic stamp on the coupling table \u2014 INCONCLUSIVE / WEAK / PLAUSIBLE / ACCEPTABLE. Tied to the number of commits in the analysis window. The weak floor of 5 matches code-maat's --min-revs default (Adam Tornhill); higher tiers are engineering judgment. ACCEPTABLE means the sample supports the ranking; it never asserts the couplings themselves are bad."};function z(e){return e.option("--top <n>","limit to top N entries (0 = all)","20").option("--format <type>","output format: json | table","json").option("--exclude <patterns...>","additional file patterns to exclude (also reads .obsignore / .obsceneignore)")}z(O.command("report").description("per-file complexity data")).action(e=>{try{Pe(e)}catch(t){N(t)}});z(O.command("hotspots",{isDefault:!0}).description("churn \xD7 complexity hotspot analysis (default)")).option("--months <n>","churn window in months","3").action(e=>{try{Ue(e)}catch(t){N(t)}});z(O.command("coupling").description("temporal coupling \u2014 files that change together across directories")).option("--months <n>","churn window in months","3").option("--min-cochanges <n>","minimum shared commits to include","2").action(e=>{try{We(e)}catch(t){N(t)}});O.command("init").description("generate a starter .obsignore based on project structure").action(()=>{try{Be()}catch(e){N(e)}});function V(e){return[...Q(),...e??[]]}function q(){!L(".obsignore")&&!L(".obsceneignore")&&process.stderr.write("hint: no .obsignore found \u2014 run `obscene init` to generate one with recommended exclusions\n")}function Pe(e){q();let t=parseInt(e.top,10),n=V(e.exclude),o=$(n),i=o.reduce((l,r)=>({totalComplexity:l.totalComplexity+r.complexity,totalCode:l.totalCode+r.code,totalLines:l.totalLines+r.lines}),{totalComplexity:0,totalCode:0,totalLines:0}),c=t>0?o.slice(0,t):o,a={generated:new Date().toISOString(),guide:_e,summary:{...i,fileCount:o.length,avgComplexityPerFile:o.length>0?Math.round(i.totalComplexity/o.length*10)/10:0,showing:c.length},files:c};e.format==="table"?process.stdout.write(`${ge(a)}
22
- `):process.stdout.write(`${JSON.stringify(a,null,2)}
23
- `)}function Ue(e){q();let t=parseInt(e.top,10),n=parseInt(e.months,10),o=V(e.exclude),i=$(o),c=P(n),a=ne(n),l=oe(n),r=ce(i.map(g=>g.file)),{rankings:m,skipped:s}=re(i,c,a,r,l,t),u=ue(m,c,t),f=0;for(let g of i)f+=g.complexity;let h={generated:new Date().toISOString(),guide:je,churnWindow:`${n} months`,rankings:m,skipped:Object.keys(s).length>0?s:void 0,composite:u,corpus:{fileCount:i.length,totalComplexity:f}};e.format==="table"?(process.stdout.write(`${de(h)}
24
- `),u.entries.length>0&&process.stdout.write(`
25
- ${ye(u)}
26
- `)):process.stdout.write(`${JSON.stringify(h,null,2)}
27
- `)}function We(e){q();let t=parseInt(e.top,10),n=parseInt(e.months,10),o=parseInt(e.minCochanges,10),i=V(e.exclude),c=$(i),a=P(n),l=ie(n,i),r=new Map;for(let b of c)r.set(b.file,b.complexity);let m=B(),s=se(l,a,r,o,m),u=t>0?s.slice(0,t):s,f={hot:0,warm:0,cool:0};for(let b of s)f[b.tier]++;let h=s.reduce((b,p)=>b+p.couplingScore,0),g={generated:new Date().toISOString(),guide:He,churnWindow:`${n} months`,minCochanges:o,totalScore:h,tierCounts:f,totalCouplings:s.length,showing:u.length,couplings:u,confidence:pe(me(n))};e.format==="table"?process.stdout.write(`${he(g)}
28
- `):process.stdout.write(`${JSON.stringify(g,null,2)}
29
- `)}function Be(){if(L(".obsignore"))throw new Error(".obsignore already exists. Remove it first to regenerate.");if(L(".obsceneignore"))throw new Error(".obsceneignore already exists. Remove it first to regenerate.");let e=ae(),t=le(e);Ne(".obsignore",t);let n=H.reduce((o,i)=>o+i.patterns.length,0);if(process.stderr.write(`Created .obsignore with ${n} universal exclusions`),e.length>0){process.stderr.write(` + ${e.length} detected patterns:
30
- `);for(let o of e)process.stderr.write(` ${o.pattern.padEnd(20)} ${o.comment}
20
+ Metric concept: change-history metrics (Moser, Pedrycz & Succi 2008) via conventional commits (fix: prefix) \xB7 Strength: direct fix-history signal \xB7 Limit: counts fix activity, not defects per se; requires consistent fix: convention`,authors:`authors \xD7 churn. Files touched by many authors and changing often may lack clear ownership. MinAuth side-column counts contributors with <5% of file commits (Bird et al. FSE 2011) \u2014 '\u2014' means the file has fewer than 2 commits, too few to call anyone *minor*.
21
+ Metric concept: code ownership research (Bird et al. 2011, Microsoft); Co-authored-by trailers folded into author set to close the squash-merge gap \xB7 Strength: flags diffuse ownership risk \xB7 Limit: doesn't measure expertise depth, bot authors filtered automatically`,composite:`Combined ranking using Reciprocal Rank Fusion (RRF) across all dimensions. Files appearing near the top of multiple rankings score highest.
22
+ Metric concept: RRF (Cormack et al. 2009) \xB7 Strength: robust to outliers, no normalization needed \xB7 Limit: equal weight across all dimensions`,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.",corpus:"Aggregate stats for the analyzed file set (post-exclude \u2014 files filtered by .obsignore or --exclude are not counted). When totalComplexity is 0, the rankings reflect size and churn only; HOT/WARM/COOL become relative groupings rather than risk labels.",confidence:"Epistemic stamp on each ranking \u2014 INCONCLUSIVE / WEAK / PLAUSIBLE / ACCEPTABLE. These are engineering-judgment sample-size tiers, with the weak floor for defects matching code-maat's --min-revs default of 5. ACCEPTABLE is the ceiling \u2014 the tool never claims certainty about code quality, only that the sample supports the ranking. INCONCLUSIVE rankings are surfaced under skipped rather than ranked."},qe={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.",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.",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.",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.",deleted:"file1Deleted / file2Deleted are set when the file is no longer present at HEAD (deleted or renamed away). The coupling signal is historical \u2014 the pair is not actionable in the current tree.",lockstep:"Set when shared commits / max(churn) \u2265 0.9 \u2014 both files almost always change together over the window. Typical of generator/mirror pairs (README \u2194 src/README, *.pb.go \u2194 *.proto). The coupling signal is real but uninformative; treat the pair as a single unit from git's perspective.",confidence:"Epistemic stamp on the coupling table \u2014 INCONCLUSIVE / WEAK / PLAUSIBLE / ACCEPTABLE. Tied to the number of commits in the analysis window. The weak floor of 5 matches code-maat's --min-revs default (Adam Tornhill); higher tiers are engineering judgment. ACCEPTABLE means the sample supports the ranking; it never asserts the couplings themselves are bad."};function V(e){return e.option("--top <n>","limit to top N entries (0 = all)","20").option("--format <type>","output format: json | table","json").option("--exclude <patterns...>","additional file patterns to exclude (also reads .obsignore / .obsceneignore)")}V(T.command("report").description("per-file complexity data")).action(e=>{try{Ke(e)}catch(t){_(t)}});V(T.command("hotspots",{isDefault:!0}).description("churn \xD7 complexity hotspot analysis (default)")).option("--months <n>","churn window in months","3").action(e=>{try{Je(e)}catch(t){_(t)}});V(T.command("coupling").description("temporal coupling \u2014 files that change together across directories")).option("--months <n>","churn window in months","3").option("--min-cochanges <n>","minimum shared commits to include","2").action(e=>{try{Ye(e)}catch(t){_(t)}});T.command("init").description("generate a starter .obsignore based on project structure").action(()=>{try{Xe()}catch(e){_(e)}});function q(e){return[...Z(),...e??[]]}function K(){!L(".obsignore")&&!L(".obsceneignore")&&process.stderr.write("hint: no .obsignore found \u2014 run `obscene init` to generate one with recommended exclusions\n")}function we(e){let t=ge(e);return t.underCovered&&process.stderr.write(`warning: git history covers ~${t.spanDays}d, but --months window is ${t.windowDays}d \u2014 count-based confidence won't reflect time-based trust on a young repo
23
+ `),t}function Ke(e){K();let t=parseInt(e.top,10),n=q(e.exclude),i=D(n),o=i.reduce((m,r)=>({totalComplexity:m.totalComplexity+r.complexity,totalCode:m.totalCode+r.code,totalLines:m.totalLines+r.lines}),{totalComplexity:0,totalCode:0,totalLines:0}),s=t>0?i.slice(0,t):i,c={generated:new Date().toISOString(),guide:ze,summary:{...o,fileCount:i.length,avgComplexityPerFile:i.length>0?Math.round(o.totalComplexity/i.length*10)/10:0,showing:s.length},files:s};e.format==="table"?process.stdout.write(`${he(c)}
24
+ `):process.stdout.write(`${JSON.stringify(c,null,2)}
25
+ `)}function Je(e){K();let t=parseInt(e.top,10),n=parseInt(e.months,10),i=we(n),o=q(e.exclude),s=D(o),c=P(n),m=oe(n),r=ie(n),l=new Map;for(let[C,u]of r)l.set(C,u.size);let a=ae(s.map(C=>C.file)),{rankings:p,skipped:f}=se(s,c,m,a,l,t,r),g=pe(p,c,t),h=0;for(let C of s)h+=C.complexity;let b={generated:new Date().toISOString(),guide:Ve,churnWindow:`${n} months`,historyCoverage:i,rankings:p,skipped:Object.keys(f).length>0?f:void 0,composite:g,corpus:{fileCount:s.length,totalComplexity:h}};e.format==="table"?(process.stdout.write(`${ye(b)}
26
+ `),g.entries.length>0&&process.stdout.write(`
27
+ ${Ce(g)}
28
+ `)):process.stdout.write(`${JSON.stringify(b,null,2)}
29
+ `)}function Ye(e){K();let t=parseInt(e.top,10),n=parseInt(e.months,10),i=parseInt(e.minCochanges,10),o=we(n),s=q(e.exclude),c=D(s),m=P(n),r=re(n,s),l=new Map;for(let C of c)l.set(C.file,C.complexity);let a=G(),p=ce(r,m,l,i,a),f=t>0?p.slice(0,t):p,g={hot:0,warm:0,cool:0};for(let C of p)g[C.tier]++;let h=p.reduce((C,u)=>C+u.couplingScore,0),b={generated:new Date().toISOString(),guide:qe,churnWindow:`${n} months`,historyCoverage:o,minCochanges:i,totalScore:h,tierCounts:g,totalCouplings:p.length,showing:f.length,couplings:f,confidence:me(fe(n))};e.format==="table"?process.stdout.write(`${be(b)}
30
+ `):process.stdout.write(`${JSON.stringify(b,null,2)}
31
+ `)}function Xe(){if(L(".obsignore"))throw new Error(".obsignore already exists. Remove it first to regenerate.");if(L(".obsceneignore"))throw new Error(".obsceneignore already exists. Remove it first to regenerate.");let e=le(),t=ue(e);We(".obsignore",t);let n=U.reduce((i,o)=>i+o.patterns.length,0);if(process.stderr.write(`Created .obsignore with ${n} universal exclusions`),e.length>0){process.stderr.write(` + ${e.length} detected patterns:
32
+ `);for(let i of e)process.stderr.write(` ${i.pattern.padEnd(20)} ${i.comment}
31
33
  `)}else process.stderr.write(` (no project-specific patterns detected)
32
- `)}function N(e){let t=e instanceof Error?e.message:String(e);process.stderr.write(`Error: ${t}
33
- `),process.exit(1)}O.parse();
34
+ `)}function _(e){let t=e instanceof Error?e.message:String(e);process.stderr.write(`Error: ${t}
35
+ `),process.exit(1)}T.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "2.2.2",
3
+ "version": "2.4.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": {
@@ -34,7 +34,7 @@
34
34
  "technical-debt"
35
35
  ],
36
36
  "license": "MIT",
37
- "author": "William Bernting",
37
+ "author": "wbern",
38
38
  "repository": {
39
39
  "type": "git",
40
40
  "url": "https://github.com/wbern/obscene.git"