@wbern/obscene 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/cli.js +116 -13
- package/package.json +15 -14
package/README.md
CHANGED
|
@@ -25,6 +25,20 @@ Works on any language scc supports. No configuration needed.
|
|
|
25
25
|
|
|
26
26
|
[scc](https://github.com/boyter/scc#install) must be installed and on your PATH.
|
|
27
27
|
|
|
28
|
+
```bash
|
|
29
|
+
brew install scc # macOS
|
|
30
|
+
choco install scc # Windows
|
|
31
|
+
scoop install scc # Windows (alt)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
See [scc install docs](https://github.com/boyter/scc#install) for Linux and other options.
|
|
35
|
+
|
|
36
|
+
## Quick run (no install)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm dlx @wbern/obscene --format table
|
|
40
|
+
```
|
|
41
|
+
|
|
28
42
|
## Install
|
|
29
43
|
|
|
30
44
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
|
|
6
6
|
// src/analyze.ts
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
|
+
import { readFileSync } from "fs";
|
|
8
9
|
var DEFAULT_EXCLUDES = [
|
|
9
10
|
/\.test\./,
|
|
10
11
|
/\.spec\./,
|
|
@@ -27,7 +28,8 @@ function globToRegex(pattern) {
|
|
|
27
28
|
return new RegExp(escaped);
|
|
28
29
|
}
|
|
29
30
|
function normalizePath(p) {
|
|
30
|
-
|
|
31
|
+
const forwardSlash = p.replaceAll("\\", "/");
|
|
32
|
+
return forwardSlash.startsWith("./") ? forwardSlash.slice(2) : forwardSlash;
|
|
31
33
|
}
|
|
32
34
|
function runScc(excludes = []) {
|
|
33
35
|
const patterns = [...DEFAULT_EXCLUDES, ...excludes.map(globToRegex)];
|
|
@@ -63,31 +65,123 @@ function runScc(excludes = []) {
|
|
|
63
65
|
}
|
|
64
66
|
return files.sort((a, b) => b.complexity - a.complexity);
|
|
65
67
|
}
|
|
68
|
+
function gitFileCount(gitArgs, errorMessage) {
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = execSync(gitArgs, {
|
|
72
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(errorMessage);
|
|
77
|
+
}
|
|
78
|
+
const counts = /* @__PURE__ */ new Map();
|
|
79
|
+
for (const line of raw.toString().split("\n")) {
|
|
80
|
+
const trimmed = normalizePath(line.trim());
|
|
81
|
+
if (!trimmed) continue;
|
|
82
|
+
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
|
|
83
|
+
}
|
|
84
|
+
return counts;
|
|
85
|
+
}
|
|
66
86
|
function getChurn(months) {
|
|
87
|
+
return gitFileCount(
|
|
88
|
+
`git log --since="${months} months ago" --format="" --name-only`,
|
|
89
|
+
"Not a git repository or git is not installed."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function getDefects(months) {
|
|
93
|
+
return gitFileCount(
|
|
94
|
+
`git log --since="${months} months ago" --grep="^fix" --format="" --name-only`,
|
|
95
|
+
"Not a git repository or git is not installed."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
function getAuthors(months) {
|
|
67
99
|
let raw;
|
|
68
100
|
try {
|
|
69
101
|
raw = execSync(
|
|
70
|
-
`git log --since="${months} months ago" --format="" --name-only`,
|
|
102
|
+
`git log --since="${months} months ago" --format="COMMIT_SEP%n%aN" --name-only`,
|
|
71
103
|
{ maxBuffer: 50 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] }
|
|
72
104
|
);
|
|
73
105
|
} catch {
|
|
74
106
|
throw new Error("Not a git repository or git is not installed.");
|
|
75
107
|
}
|
|
108
|
+
const authorSets = /* @__PURE__ */ new Map();
|
|
109
|
+
const blocks = raw.toString().split("COMMIT_SEP\n");
|
|
110
|
+
for (const block of blocks) {
|
|
111
|
+
if (!block.trim()) continue;
|
|
112
|
+
const lines = block.split("\n");
|
|
113
|
+
const author = lines[0].trim();
|
|
114
|
+
if (!author) continue;
|
|
115
|
+
for (let i = 1; i < lines.length; i++) {
|
|
116
|
+
const file = normalizePath(lines[i].trim());
|
|
117
|
+
if (!file) continue;
|
|
118
|
+
let set = authorSets.get(file);
|
|
119
|
+
if (!set) {
|
|
120
|
+
set = /* @__PURE__ */ new Set();
|
|
121
|
+
authorSets.set(file, set);
|
|
122
|
+
}
|
|
123
|
+
set.add(author);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
76
126
|
const counts = /* @__PURE__ */ new Map();
|
|
77
|
-
for (const
|
|
78
|
-
|
|
79
|
-
if (!trimmed) continue;
|
|
80
|
-
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
|
|
127
|
+
for (const [file, set] of authorSets) {
|
|
128
|
+
counts.set(file, set.size);
|
|
81
129
|
}
|
|
82
130
|
return counts;
|
|
83
131
|
}
|
|
84
|
-
function
|
|
132
|
+
function getNestingDepths(filePaths) {
|
|
133
|
+
const depths = /* @__PURE__ */ new Map();
|
|
134
|
+
for (const filePath of filePaths) {
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(filePath, "utf-8");
|
|
138
|
+
} catch {
|
|
139
|
+
depths.set(filePath, 0);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
let minSpaces = Number.POSITIVE_INFINITY;
|
|
143
|
+
const leadings = [];
|
|
144
|
+
for (const line of content.split("\n")) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
const match = line.match(/^(\s+)/);
|
|
147
|
+
if (!match) continue;
|
|
148
|
+
const leading = match[1];
|
|
149
|
+
leadings.push(leading);
|
|
150
|
+
const spaceCount = (leading.match(/ /g) ?? []).length;
|
|
151
|
+
if (spaceCount > 0 && !leading.includes(" ") && spaceCount < minSpaces) {
|
|
152
|
+
minSpaces = spaceCount;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const indentUnit = minSpaces === Number.POSITIVE_INFINITY ? 4 : minSpaces;
|
|
156
|
+
let maxDepth = 0;
|
|
157
|
+
for (const leading of leadings) {
|
|
158
|
+
let depth = 0;
|
|
159
|
+
for (const ch of leading) {
|
|
160
|
+
if (ch === " ") {
|
|
161
|
+
depth += 1;
|
|
162
|
+
} else if (ch === " ") {
|
|
163
|
+
depth += 1 / indentUnit;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
depth = Math.floor(depth);
|
|
167
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
168
|
+
}
|
|
169
|
+
depths.set(filePath, maxDepth);
|
|
170
|
+
}
|
|
171
|
+
return depths;
|
|
172
|
+
}
|
|
173
|
+
function computeHotspots(files, churn, defects = /* @__PURE__ */ new Map(), nestingDepths = /* @__PURE__ */ new Map(), authors = /* @__PURE__ */ new Map()) {
|
|
85
174
|
const scored = files.map((f) => {
|
|
86
175
|
const fileChurn = churn.get(f.file) ?? 0;
|
|
176
|
+
const fileDefects = defects.get(f.file) ?? 0;
|
|
87
177
|
return {
|
|
88
178
|
...f,
|
|
89
179
|
churn: fileChurn,
|
|
90
|
-
hotspotScore: f.complexity * fileChurn
|
|
180
|
+
hotspotScore: f.complexity * fileChurn,
|
|
181
|
+
defects: fileDefects,
|
|
182
|
+
defectDensity: f.code > 0 ? Math.round(fileDefects / f.code * 1e4) / 1e4 : 0,
|
|
183
|
+
maxNesting: nestingDepths.get(f.file) ?? 0,
|
|
184
|
+
authors: authors.get(f.file) ?? 0
|
|
91
185
|
};
|
|
92
186
|
}).filter((h) => h.hotspotScore > 0).sort((a, b) => b.hotspotScore - a.hotspotScore);
|
|
93
187
|
const totalScore = scored.reduce((sum, h) => sum + h.hotspotScore, 0);
|
|
@@ -143,13 +237,13 @@ function formatHotspotsTable(output) {
|
|
|
143
237
|
lines.push(`Showing: ${output.showing} of ${output.totalHotspots}`);
|
|
144
238
|
lines.push("");
|
|
145
239
|
lines.push(
|
|
146
|
-
padRight("File", 50) + padLeft("Score", 8) + padLeft("%", 7) + padLeft("Churn", 7) + padLeft("Cmplx", 7) + padLeft("
|
|
240
|
+
padRight("File", 50) + padLeft("Score", 8) + padLeft("%", 7) + padLeft("Churn", 7) + padLeft("Cmplx", 7) + padLeft("Dens", 7) + padLeft("Dfcts", 6) + padLeft("Nest", 6) + padLeft("Auth", 6) + padLeft("Tier", 8)
|
|
147
241
|
);
|
|
148
|
-
lines.push("\u2500".repeat(
|
|
242
|
+
lines.push("\u2500".repeat(112));
|
|
149
243
|
for (const h of hotspots) {
|
|
150
244
|
const tierLabel = h.tier === "danger" ? "DANGER" : h.tier === "watch" ? "WATCH" : "stable";
|
|
151
245
|
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),
|
|
246
|
+
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), 7) + padLeft(String(h.defects), 6) + padLeft(String(h.maxNesting), 6) + padLeft(String(h.authors), 6) + padLeft(tierLabel, 8)
|
|
153
247
|
);
|
|
154
248
|
}
|
|
155
249
|
return lines.join("\n");
|
|
@@ -166,7 +260,7 @@ function truncate(s, max) {
|
|
|
166
260
|
|
|
167
261
|
// src/cli.ts
|
|
168
262
|
var program = new Command();
|
|
169
|
-
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.
|
|
263
|
+
program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.2.0");
|
|
170
264
|
function addSharedOptions(cmd) {
|
|
171
265
|
return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
|
|
172
266
|
"--exclude <patterns...>",
|
|
@@ -226,7 +320,16 @@ function runHotspots(opts) {
|
|
|
226
320
|
const months = parseInt(opts.months, 10);
|
|
227
321
|
const files = runScc(opts.exclude);
|
|
228
322
|
const churn = getChurn(months);
|
|
229
|
-
const
|
|
323
|
+
const defects = getDefects(months);
|
|
324
|
+
const authors = getAuthors(months);
|
|
325
|
+
const nestingDepths = getNestingDepths(files.map((f) => f.file));
|
|
326
|
+
const hotspots = computeHotspots(
|
|
327
|
+
files,
|
|
328
|
+
churn,
|
|
329
|
+
defects,
|
|
330
|
+
nestingDepths,
|
|
331
|
+
authors
|
|
332
|
+
);
|
|
230
333
|
const limited = top > 0 ? hotspots.slice(0, top) : hotspots;
|
|
231
334
|
const tierCounts = { danger: 0, watch: 0, stable: 0 };
|
|
232
335
|
for (const h of hotspots) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbern/obscene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": {
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"prepublishOnly": "node scripts/check-publish.js && pnpm run build",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:coverage": "vitest run --coverage",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "biome check .",
|
|
19
|
+
"lint:fix": "biome check --write .",
|
|
20
|
+
"knip": "knip --no-config-hints",
|
|
21
|
+
"duplication-check": "jscpd",
|
|
22
|
+
"markdownlint": "markdownlint --fix **/*.md --ignore node_modules",
|
|
23
|
+
"prepare": "husky && (claude-instructions --scope=project --prefix= --overwrite || echo .)"
|
|
24
|
+
},
|
|
12
25
|
"keywords": [
|
|
13
26
|
"git",
|
|
14
27
|
"complexity",
|
|
@@ -80,17 +93,5 @@
|
|
|
80
93
|
}
|
|
81
94
|
]
|
|
82
95
|
]
|
|
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
|
}
|
|
96
|
-
}
|
|
97
|
+
}
|