@wbern/obscene 0.1.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 +21 -0
  2. package/README.md +109 -0
  3. package/dist/cli.js +259 -0
  4. package/package.json +96 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Bernting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @wbern/obscene
2
+
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
+ ```
17
+
18
+ **Find hotspot files — complex code that changes frequently.**
19
+
20
+ 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
+
22
+ Works on any language scc supports. No configuration needed.
23
+
24
+ ## Prerequisites
25
+
26
+ [scc](https://github.com/boyter/scc#install) must be installed and on your PATH.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pnpm add -g @wbern/obscene
32
+ ```
33
+
34
+ ```bash
35
+ npm install -g @wbern/obscene # also works
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ obscene # top 20 hotspots as JSON
42
+ obscene --format table # human-readable table
43
+ obscene --top 50 --months 6 # more results, longer window
44
+ obscene --top 0 # all files
45
+ obscene report # raw complexity (no churn)
46
+ obscene --exclude "*.generated.*"
47
+ obscene | jq '.hotspots[0]' # pipe-friendly
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ ### `obscene hotspots` (default)
53
+
54
+ Scores each file by `complexity × commits` over a time window, then assigns tiers by cumulative score distribution:
55
+
56
+ | Tier | Range | Meaning |
57
+ |------|-------|---------|
58
+ | **danger** | top 50% of total score | Refactor candidates |
59
+ | **watch** | next 30% (50–80%) | Keep an eye on these |
60
+ | **stable** | bottom 20% | Low risk |
61
+
62
+ ### `obscene report`
63
+
64
+ Per-file complexity without churn. Useful for raw complexity distribution.
65
+
66
+ ## Options
67
+
68
+ | Flag | Default | Description |
69
+ |------|---------|-------------|
70
+ | `--top <n>` | `20` | Limit results (0 = all) |
71
+ | `--months <n>` | `3` | Churn window in months |
72
+ | `--format <type>` | `json` | `json` or `table` |
73
+ | `--exclude <patterns...>` | — | Additional exclusion patterns |
74
+
75
+ ## Example output
76
+
77
+ ```
78
+ Hotspots — 3 months churn window | Total score: 35452
79
+ Tiers: 3 danger, 13 watch, 194 stable
80
+ Showing: 5 of 210
81
+
82
+ File Score % Churn Cmplx Density Tier
83
+ ──────────────────────────────────────────────────────────────────────────────────────
84
+ src/utils/effect-generator.ts 8296 23.4 68 122 0.12 DANGER
85
+ src/services/game-engine.ts 4284 12.1 51 84 0.09 DANGER
86
+ src/components/board-renderer.tsx 2940 8.3 42 70 0.11 DANGER
87
+ src/hooks/use-game-state.ts 1320 3.7 33 40 0.08 WATCH
88
+ src/utils/move-validator.ts 945 2.7 27 35 0.06 WATCH
89
+ ```
90
+
91
+ ## Supported languages
92
+
93
+ 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.
94
+
95
+ ## Default exclusions
96
+
97
+ 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`).
98
+
99
+ ## Limitations
100
+
101
+ - **Churn = commit count**, not lines changed. A one-line typo fix counts the same as a 500-line rewrite.
102
+ - **Per-file granularity only.** A 1000-line file with many small functions scores higher than it probably should. No function-level breakdown.
103
+ - **Must be run inside a git repo.** Churn data comes from `git log`.
104
+ - **Only analyzes files that currently exist.** Deleted files don't appear, even if they churned heavily before removal.
105
+ - **Tier thresholds are fixed** (50/80 cumulative %). Not configurable yet.
106
+
107
+ ## License
108
+
109
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/analyze.ts
7
+ import { execSync } from "child_process";
8
+ var DEFAULT_EXCLUDES = [
9
+ /\.test\./,
10
+ /\.spec\./,
11
+ /\.integration\.test\./,
12
+ /test-setup\./,
13
+ /test-utils\./,
14
+ /test-helpers\./,
15
+ /__tests__\//,
16
+ /__mocks__\//,
17
+ /\.stories\./,
18
+ /\.d\.ts$/
19
+ ];
20
+ var DANGER_CUMULATIVE = 0.5;
21
+ var WATCH_CUMULATIVE = 0.8;
22
+ function isExcluded(location, patterns) {
23
+ return patterns.some((p) => p.test(location));
24
+ }
25
+ function globToRegex(pattern) {
26
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u27E8GLOBSTAR\u27E9").replace(/\*/g, "[^/]*").replace(/⟨GLOBSTAR⟩/g, ".*").replace(/\?/g, ".");
27
+ return new RegExp(escaped);
28
+ }
29
+ function normalizePath(p) {
30
+ return p.startsWith("./") ? p.slice(2) : p;
31
+ }
32
+ function runScc(excludes = []) {
33
+ const patterns = [...DEFAULT_EXCLUDES, ...excludes.map(globToRegex)];
34
+ let raw;
35
+ try {
36
+ raw = execSync("scc --by-file --format json --no-cocomo --no-gen", {
37
+ maxBuffer: 50 * 1024 * 1024,
38
+ stdio: ["pipe", "pipe", "pipe"]
39
+ });
40
+ } catch (err) {
41
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
42
+ throw new Error(
43
+ "scc not found. Install it: https://github.com/boyter/scc#install"
44
+ );
45
+ }
46
+ throw err;
47
+ }
48
+ const languages = JSON.parse(raw.toString());
49
+ const files = [];
50
+ for (const lang of languages) {
51
+ for (const f of lang.Files) {
52
+ const normalized = normalizePath(f.Location);
53
+ if (isExcluded(normalized, patterns)) continue;
54
+ files.push({
55
+ file: normalized,
56
+ code: f.Code,
57
+ lines: f.Lines,
58
+ complexity: f.Complexity,
59
+ comments: f.Comment,
60
+ complexityDensity: f.Code > 0 ? Math.round(f.Complexity / f.Code * 100) / 100 : 0
61
+ });
62
+ }
63
+ }
64
+ return files.sort((a, b) => b.complexity - a.complexity);
65
+ }
66
+ function getChurn(months) {
67
+ let raw;
68
+ try {
69
+ raw = execSync(
70
+ `git log --since="${months} months ago" --format="" --name-only`,
71
+ { maxBuffer: 50 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] }
72
+ );
73
+ } catch {
74
+ throw new Error("Not a git repository or git is not installed.");
75
+ }
76
+ const counts = /* @__PURE__ */ new Map();
77
+ for (const line of raw.toString().split("\n")) {
78
+ const trimmed = normalizePath(line.trim());
79
+ if (!trimmed) continue;
80
+ counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
81
+ }
82
+ return counts;
83
+ }
84
+ function computeHotspots(files, churn) {
85
+ const scored = files.map((f) => {
86
+ const fileChurn = churn.get(f.file) ?? 0;
87
+ return {
88
+ ...f,
89
+ churn: fileChurn,
90
+ hotspotScore: f.complexity * fileChurn
91
+ };
92
+ }).filter((h) => h.hotspotScore > 0).sort((a, b) => b.hotspotScore - a.hotspotScore);
93
+ const totalScore = scored.reduce((sum, h) => sum + h.hotspotScore, 0);
94
+ if (totalScore === 0) return [];
95
+ let cumulative = 0;
96
+ return scored.map((h) => {
97
+ const percentOfTotal = Math.round(h.hotspotScore / totalScore * 1e3) / 10;
98
+ cumulative += h.hotspotScore;
99
+ const cumulativeShare = cumulative / totalScore;
100
+ let tier;
101
+ if (cumulativeShare <= DANGER_CUMULATIVE) {
102
+ tier = "danger";
103
+ } else if (cumulativeShare <= WATCH_CUMULATIVE) {
104
+ tier = "watch";
105
+ } else {
106
+ tier = "stable";
107
+ }
108
+ return { ...h, percentOfTotal, tier };
109
+ });
110
+ }
111
+
112
+ // src/format.ts
113
+ function formatReportTable(output) {
114
+ const lines = [];
115
+ const { summary, files } = output;
116
+ lines.push(
117
+ `Complexity Report \u2014 ${summary.fileCount} files, ${summary.totalComplexity} total complexity`
118
+ );
119
+ lines.push(
120
+ `Showing: ${summary.showing} | Avg complexity/file: ${summary.avgComplexityPerFile}`
121
+ );
122
+ lines.push("");
123
+ lines.push(
124
+ padRight("File", 60) + padLeft("Code", 8) + padLeft("Complexity", 12) + padLeft("Density", 9) + padLeft("Comments", 10)
125
+ );
126
+ lines.push("\u2500".repeat(99));
127
+ for (const f of files) {
128
+ lines.push(
129
+ padRight(truncate(f.file, 58), 60) + padLeft(String(f.code), 8) + padLeft(String(f.complexity), 12) + padLeft(f.complexityDensity.toFixed(2), 9) + padLeft(String(f.comments), 10)
130
+ );
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+ function formatHotspotsTable(output) {
135
+ const lines = [];
136
+ const { tierCounts, totalScore, churnWindow, hotspots } = output;
137
+ lines.push(
138
+ `Hotspots \u2014 ${churnWindow} churn window | Total score: ${totalScore.toLocaleString()}`
139
+ );
140
+ lines.push(
141
+ `Tiers: ${tierCounts.danger} danger, ${tierCounts.watch} watch, ${tierCounts.stable} stable`
142
+ );
143
+ lines.push(`Showing: ${output.showing} of ${output.totalHotspots}`);
144
+ lines.push("");
145
+ lines.push(
146
+ padRight("File", 50) + padLeft("Score", 8) + padLeft("%", 7) + padLeft("Churn", 7) + padLeft("Cmplx", 7) + padLeft("Density", 9) + padLeft("Tier", 8)
147
+ );
148
+ lines.push("\u2500".repeat(96));
149
+ for (const h of hotspots) {
150
+ const tierLabel = h.tier === "danger" ? "DANGER" : h.tier === "watch" ? "WATCH" : "stable";
151
+ lines.push(
152
+ padRight(truncate(h.file, 48), 50) + padLeft(h.hotspotScore.toLocaleString(), 8) + padLeft(h.percentOfTotal.toFixed(1), 7) + padLeft(String(h.churn), 7) + padLeft(String(h.complexity), 7) + padLeft(h.complexityDensity.toFixed(2), 9) + padLeft(tierLabel, 8)
153
+ );
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+ function padRight(s, n) {
158
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
159
+ }
160
+ function padLeft(s, n) {
161
+ return s.length >= n ? s : " ".repeat(n - s.length) + s;
162
+ }
163
+ function truncate(s, max) {
164
+ return s.length <= max ? s : `\u2026${s.slice(s.length - max + 1)}`;
165
+ }
166
+
167
+ // src/cli.ts
168
+ var program = new Command();
169
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.1.0");
170
+ function addSharedOptions(cmd) {
171
+ return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
172
+ "--exclude <patterns...>",
173
+ "additional file patterns to exclude (e.g. *.generated.*)"
174
+ );
175
+ }
176
+ addSharedOptions(
177
+ program.command("report").description("per-file complexity data")
178
+ ).action((opts) => {
179
+ try {
180
+ runReport(opts);
181
+ } catch (err) {
182
+ exitWithError(err);
183
+ }
184
+ });
185
+ addSharedOptions(
186
+ program.command("hotspots", { isDefault: true }).description("churn \xD7 complexity hotspot analysis (default)")
187
+ ).option("--months <n>", "churn window in months", "3").action((opts) => {
188
+ try {
189
+ runHotspots(opts);
190
+ } catch (err) {
191
+ exitWithError(err);
192
+ }
193
+ });
194
+ function runReport(opts) {
195
+ const top = parseInt(opts.top, 10);
196
+ const files = runScc(opts.exclude);
197
+ const totals = files.reduce(
198
+ (acc, f) => ({
199
+ totalComplexity: acc.totalComplexity + f.complexity,
200
+ totalCode: acc.totalCode + f.code,
201
+ totalLines: acc.totalLines + f.lines
202
+ }),
203
+ { totalComplexity: 0, totalCode: 0, totalLines: 0 }
204
+ );
205
+ const limited = top > 0 ? files.slice(0, top) : files;
206
+ const output = {
207
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
208
+ summary: {
209
+ ...totals,
210
+ fileCount: files.length,
211
+ avgComplexityPerFile: files.length > 0 ? Math.round(totals.totalComplexity / files.length * 10) / 10 : 0,
212
+ showing: limited.length
213
+ },
214
+ files: limited
215
+ };
216
+ if (opts.format === "table") {
217
+ process.stdout.write(`${formatReportTable(output)}
218
+ `);
219
+ } else {
220
+ process.stdout.write(`${JSON.stringify(output, null, 2)}
221
+ `);
222
+ }
223
+ }
224
+ function runHotspots(opts) {
225
+ const top = parseInt(opts.top, 10);
226
+ const months = parseInt(opts.months, 10);
227
+ const files = runScc(opts.exclude);
228
+ const churn = getChurn(months);
229
+ const hotspots = computeHotspots(files, churn);
230
+ const limited = top > 0 ? hotspots.slice(0, top) : hotspots;
231
+ const tierCounts = { danger: 0, watch: 0, stable: 0 };
232
+ for (const h of hotspots) {
233
+ tierCounts[h.tier]++;
234
+ }
235
+ const totalScore = hotspots.reduce((sum, h) => sum + h.hotspotScore, 0);
236
+ const output = {
237
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
238
+ churnWindow: `${months} months`,
239
+ totalScore,
240
+ tierCounts,
241
+ totalHotspots: hotspots.length,
242
+ showing: limited.length,
243
+ hotspots: limited
244
+ };
245
+ if (opts.format === "table") {
246
+ process.stdout.write(`${formatHotspotsTable(output)}
247
+ `);
248
+ } else {
249
+ process.stdout.write(`${JSON.stringify(output, null, 2)}
250
+ `);
251
+ }
252
+ }
253
+ function exitWithError(err) {
254
+ const message = err instanceof Error ? err.message : String(err);
255
+ process.stderr.write(`Error: ${message}
256
+ `);
257
+ process.exit(1);
258
+ }
259
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@wbern/obscene",
3
+ "version": "0.1.0",
4
+ "description": "Identify hotspot files — complex code that changes frequently. Churn × complexity analysis for any git repo.",
5
+ "type": "module",
6
+ "bin": {
7
+ "obscene": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "keywords": [
13
+ "git",
14
+ "complexity",
15
+ "churn",
16
+ "hotspot",
17
+ "code-quality",
18
+ "scc",
19
+ "static-analysis",
20
+ "code-review",
21
+ "technical-debt"
22
+ ],
23
+ "license": "MIT",
24
+ "author": "William Bernting",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/wbern/obscene.git"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "homepage": "https://github.com/wbern/obscene#readme",
33
+ "bugs": "https://github.com/wbern/obscene/issues",
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "dependencies": {
38
+ "commander": "^13.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^2.0.0",
42
+ "@commitlint/cli": "^20.4.2",
43
+ "@commitlint/config-conventional": "^20.4.2",
44
+ "@secretlint/secretlint-rule-preset-recommend": "^11.3.1",
45
+ "@semantic-release/commit-analyzer": "^13.0.1",
46
+ "@semantic-release/git": "^10.0.1",
47
+ "@semantic-release/github": "^12.0.6",
48
+ "@semantic-release/npm": "^13.1.5",
49
+ "@semantic-release/release-notes-generator": "^14.1.0",
50
+ "@types/node": "^22.15.2",
51
+ "@vitest/coverage-v8": "^3.1.0",
52
+ "@wbern/claude-instructions": "^2.11.0",
53
+ "husky": "^9.1.7",
54
+ "jscpd": "^4.0.8",
55
+ "knip": "^5.84.1",
56
+ "lint-staged": "^16.2.0",
57
+ "markdownlint-cli": "^0.44.0",
58
+ "secretlint": "^11.3.1",
59
+ "semantic-release": "^25.0.3",
60
+ "tsup": "^8.4.0",
61
+ "typescript": "^5.8.3",
62
+ "vitest": "^3.1.0"
63
+ },
64
+ "release": {
65
+ "branches": [
66
+ "main"
67
+ ],
68
+ "plugins": [
69
+ "@semantic-release/commit-analyzer",
70
+ "@semantic-release/release-notes-generator",
71
+ "@semantic-release/npm",
72
+ "@semantic-release/github",
73
+ [
74
+ "@semantic-release/git",
75
+ {
76
+ "assets": [
77
+ "package.json"
78
+ ],
79
+ "message": "chore(release): ${nextRelease.version}"
80
+ }
81
+ ]
82
+ ]
83
+ },
84
+ "scripts": {
85
+ "postinstall": "claude-instructions --scope=project --prefix= --overwrite || true",
86
+ "build": "tsup",
87
+ "test": "vitest run",
88
+ "test:coverage": "vitest run --coverage",
89
+ "typecheck": "tsc --noEmit",
90
+ "lint": "biome check .",
91
+ "lint:fix": "biome check --write .",
92
+ "knip": "knip --no-config-hints",
93
+ "duplication-check": "jscpd",
94
+ "markdownlint": "markdownlint --fix '**/*.md' --ignore node_modules"
95
+ }
96
+ }