@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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.js +116 -13
  3. 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
- return p.startsWith("./") ? p.slice(2) : p;
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 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);
127
+ for (const [file, set] of authorSets) {
128
+ counts.set(file, set.size);
81
129
  }
82
130
  return counts;
83
131
  }
84
- function computeHotspots(files, churn) {
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("Density", 9) + padLeft("Tier", 8)
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(96));
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), 9) + padLeft(tierLabel, 8)
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.1.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 hotspots = computeHotspots(files, churn);
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.1.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
+ }