@wbern/obscene 0.1.1 → 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 (2) hide show
  1. package/dist/cli.js +114 -12
  2. package/package.json +1 -1
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\./,
@@ -64,31 +65,123 @@ function runScc(excludes = []) {
64
65
  }
65
66
  return files.sort((a, b) => b.complexity - a.complexity);
66
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
+ }
67
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) {
68
99
  let raw;
69
100
  try {
70
101
  raw = execSync(
71
- `git log --since="${months} months ago" --format="" --name-only`,
102
+ `git log --since="${months} months ago" --format="COMMIT_SEP%n%aN" --name-only`,
72
103
  { maxBuffer: 50 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] }
73
104
  );
74
105
  } catch {
75
106
  throw new Error("Not a git repository or git is not installed.");
76
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
+ }
77
126
  const counts = /* @__PURE__ */ new Map();
78
- for (const line of raw.toString().split("\n")) {
79
- const trimmed = normalizePath(line.trim());
80
- if (!trimmed) continue;
81
- counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
127
+ for (const [file, set] of authorSets) {
128
+ counts.set(file, set.size);
82
129
  }
83
130
  return counts;
84
131
  }
85
- 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()) {
86
174
  const scored = files.map((f) => {
87
175
  const fileChurn = churn.get(f.file) ?? 0;
176
+ const fileDefects = defects.get(f.file) ?? 0;
88
177
  return {
89
178
  ...f,
90
179
  churn: fileChurn,
91
- 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
92
185
  };
93
186
  }).filter((h) => h.hotspotScore > 0).sort((a, b) => b.hotspotScore - a.hotspotScore);
94
187
  const totalScore = scored.reduce((sum, h) => sum + h.hotspotScore, 0);
@@ -144,13 +237,13 @@ function formatHotspotsTable(output) {
144
237
  lines.push(`Showing: ${output.showing} of ${output.totalHotspots}`);
145
238
  lines.push("");
146
239
  lines.push(
147
- 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)
148
241
  );
149
- lines.push("\u2500".repeat(96));
242
+ lines.push("\u2500".repeat(112));
150
243
  for (const h of hotspots) {
151
244
  const tierLabel = h.tier === "danger" ? "DANGER" : h.tier === "watch" ? "WATCH" : "stable";
152
245
  lines.push(
153
- 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)
154
247
  );
155
248
  }
156
249
  return lines.join("\n");
@@ -167,7 +260,7 @@ function truncate(s, max) {
167
260
 
168
261
  // src/cli.ts
169
262
  var program = new Command();
170
- program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.1.1");
263
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("0.2.0");
171
264
  function addSharedOptions(cmd) {
172
265
  return cmd.option("--top <n>", "limit to top N entries (0 = all)", "20").option("--format <type>", "output format: json | table", "json").option(
173
266
  "--exclude <patterns...>",
@@ -227,7 +320,16 @@ function runHotspots(opts) {
227
320
  const months = parseInt(opts.months, 10);
228
321
  const files = runScc(opts.exclude);
229
322
  const churn = getChurn(months);
230
- 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
+ );
231
333
  const limited = top > 0 ? hotspots.slice(0, top) : hotspots;
232
334
  const tierCounts = { danger: 0, watch: 0, stable: 0 };
233
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.1",
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": {