@wbern/obscene 1.4.0 → 2.0.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 +18 -2
  2. package/dist/cli.js +183 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -252,9 +252,25 @@ Docs: https://github.com/wbern/obscene#metrics
252
252
 
253
253
  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.
254
254
 
255
- ## Default exclusions
255
+ ## Exclusions
256
256
 
257
- Test files, lock files, and package manifests are excluded automatically: `*.test.*`, `*.spec.*`, `__tests__/`, `__mocks__/`, `*.stories.*`, `*.d.ts`, `package.json`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, and similar patterns. scc also skips generated files by default (`--no-gen`).
257
+ All exclusions are opt-in. Run `obscene init` to generate a `.obsignore` file with recommended patterns for your project:
258
+
259
+ ```bash
260
+ obscene init
261
+ ```
262
+
263
+ This creates a `.obsignore` containing:
264
+ - **Universal exclusions** — test files (`*.test.*`, `*.spec.*`, `__tests__/`, etc.), lock files (`package-lock.json`, `pnpm-lock.yaml`, etc.), and package manifests (`package.json`)
265
+ - **Detected project patterns** — CI directories (`.github/`), config files (`*.config.*`), vendored code, etc., based on your project structure
266
+
267
+ If no `.obsignore` or `.obsceneignore` exists, obscene prints a hint to stderr:
268
+
269
+ ```
270
+ hint: no .obsignore found — run `obscene init` to generate one with recommended exclusions
271
+ ```
272
+
273
+ scc also skips generated files by default (`--no-gen`).
258
274
 
259
275
  ## Ignore files
260
276
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { existsSync, writeFileSync } from "fs";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/analyze.ts
@@ -21,22 +22,32 @@ function readIgnoreFile() {
21
22
  }
22
23
  return [];
23
24
  }
24
- var DEFAULT_EXCLUDES = [
25
- /\.test\./,
26
- /\.spec\./,
27
- /\.integration\.test\./,
28
- /test-setup\./,
29
- /test-utils\./,
30
- /test-helpers\./,
31
- /__tests__\//,
32
- /__mocks__\//,
33
- /\.stories\./,
34
- /\.d\.ts$/,
35
- /(?:^|\/)package\.json$/,
36
- /(?:^|\/)package-lock\.json$/,
37
- /(?:^|\/)pnpm-lock\.yaml$/,
38
- /(?:^|\/)yarn\.lock$/,
39
- /(?:^|\/)bun\.lock$/
25
+ var UNIVERSAL_IGNORE_GROUPS = [
26
+ {
27
+ title: "Test files and test infrastructure",
28
+ patterns: [
29
+ { pattern: "*.test.*", comment: "Unit test files" },
30
+ { pattern: "*.spec.*", comment: "Spec test files" },
31
+ { pattern: "*.integration.test.*", comment: "Integration tests" },
32
+ { pattern: "test-setup.*", comment: "Test setup files" },
33
+ { pattern: "test-utils.*", comment: "Test utility files" },
34
+ { pattern: "test-helpers.*", comment: "Test helper files" },
35
+ { pattern: "__tests__/**", comment: "Test directories" },
36
+ { pattern: "__mocks__/**", comment: "Mock directories" },
37
+ { pattern: "*.stories.*", comment: "Storybook stories" },
38
+ { pattern: "*.d.ts", comment: "TypeScript declaration files" }
39
+ ]
40
+ },
41
+ {
42
+ title: "Lock files and package manifests",
43
+ patterns: [
44
+ { pattern: "package.json", comment: "npm package manifest" },
45
+ { pattern: "package-lock.json", comment: "npm lock file" },
46
+ { pattern: "pnpm-lock.yaml", comment: "pnpm lock file" },
47
+ { pattern: "yarn.lock", comment: "Yarn lock file" },
48
+ { pattern: "bun.lock", comment: "Bun lock file" }
49
+ ]
50
+ }
40
51
  ];
41
52
  var HOT_CUMULATIVE = 0.5;
42
53
  var WARM_CUMULATIVE = 0.8;
@@ -54,7 +65,7 @@ function normalizePath(p) {
54
65
  return forwardSlash.startsWith("./") ? forwardSlash.slice(2) : forwardSlash;
55
66
  }
56
67
  function runScc(excludes = []) {
57
- const patterns = [...DEFAULT_EXCLUDES, ...excludes.map(globToRegex)];
68
+ const patterns = excludes.map(globToRegex);
58
69
  let raw;
59
70
  try {
60
71
  raw = execSync("scc --by-file --format json --no-cocomo --no-gen", {
@@ -153,7 +164,7 @@ function getAuthors(months) {
153
164
  }
154
165
  var MAX_FILES_PER_COMMIT = 20;
155
166
  function getCoChanges(months, excludes = []) {
156
- const patterns = [...DEFAULT_EXCLUDES, ...excludes.map(globToRegex)];
167
+ const patterns = excludes.map(globToRegex);
157
168
  let raw;
158
169
  try {
159
170
  raw = execSync(
@@ -385,6 +396,110 @@ function getNestingDepths(filePaths) {
385
396
  }
386
397
  return depths;
387
398
  }
399
+ var INIT_DIR_RULES = [
400
+ {
401
+ dir: ".github",
402
+ pattern: ".github/**",
403
+ comment: "GitHub Actions and workflows"
404
+ },
405
+ {
406
+ dir: ".circleci",
407
+ pattern: ".circleci/**",
408
+ comment: "CircleCI configuration"
409
+ },
410
+ { dir: ".husky", pattern: ".husky/**", comment: "Git hooks" },
411
+ { dir: ".vscode", pattern: ".vscode/**", comment: "VS Code settings" },
412
+ { dir: ".idea", pattern: ".idea/**", comment: "JetBrains settings" },
413
+ {
414
+ dir: "scripts",
415
+ pattern: "scripts/**",
416
+ comment: "Build and utility scripts"
417
+ },
418
+ { dir: "docs", pattern: "docs/**", comment: "Documentation" },
419
+ { dir: "docker", pattern: "docker/**", comment: "Docker configuration" },
420
+ {
421
+ dir: "fixtures",
422
+ pattern: "fixtures/**",
423
+ comment: "Test fixtures"
424
+ },
425
+ {
426
+ dir: "vendor",
427
+ pattern: "vendor/**",
428
+ comment: "Vendored dependencies"
429
+ }
430
+ ];
431
+ var INIT_FILE_RULES = [
432
+ {
433
+ test: /\.generated\./,
434
+ pattern: "*.generated.*",
435
+ comment: "Generated code"
436
+ },
437
+ { test: /\.gen\.[^.]+$/, pattern: "*.gen.*", comment: "Generated code" },
438
+ {
439
+ test: /\.config\.\w/,
440
+ pattern: "*.config.*",
441
+ comment: "Configuration files"
442
+ },
443
+ {
444
+ test: /(?:^|\/)\.gitlab-ci/,
445
+ pattern: ".gitlab-ci*",
446
+ comment: "GitLab CI configuration"
447
+ }
448
+ ];
449
+ function detectIgnorePatterns() {
450
+ let raw;
451
+ try {
452
+ raw = execSync("git ls-files", {
453
+ maxBuffer: 50 * 1024 * 1024,
454
+ stdio: ["pipe", "pipe", "pipe"]
455
+ });
456
+ } catch {
457
+ throw new Error("Not a git repository or git is not installed.");
458
+ }
459
+ const trackedFiles = raw.toString().split("\n").map((l) => normalizePath(l.trim())).filter(Boolean);
460
+ const patterns = [];
461
+ const topDirs = /* @__PURE__ */ new Set();
462
+ for (const f of trackedFiles) {
463
+ const slash = f.indexOf("/");
464
+ if (slash > 0) topDirs.add(f.slice(0, slash));
465
+ }
466
+ for (const rule of INIT_DIR_RULES) {
467
+ if (topDirs.has(rule.dir)) {
468
+ patterns.push({ pattern: rule.pattern, comment: rule.comment });
469
+ }
470
+ }
471
+ for (const rule of INIT_FILE_RULES) {
472
+ if (trackedFiles.some((f) => rule.test.test(f))) {
473
+ patterns.push({ pattern: rule.pattern, comment: rule.comment });
474
+ }
475
+ }
476
+ return patterns;
477
+ }
478
+ function formatIgnoreFile(detectedPatterns, universalGroups = UNIVERSAL_IGNORE_GROUPS) {
479
+ const lines = [
480
+ "# Generated by obscene init",
481
+ "# Edit this file to customize which files are excluded from analysis.",
482
+ "# Patterns use glob syntax (same as .gitignore).",
483
+ "# See: https://github.com/wbern/obscene#ignore-files",
484
+ ""
485
+ ];
486
+ for (const group of universalGroups) {
487
+ lines.push(`# ${group.title}`);
488
+ for (const p of group.patterns) {
489
+ lines.push(p.pattern);
490
+ }
491
+ lines.push("");
492
+ }
493
+ if (detectedPatterns.length > 0) {
494
+ lines.push("# Project-specific patterns");
495
+ for (const p of detectedPatterns) {
496
+ lines.push(`# ${p.comment}`);
497
+ lines.push(p.pattern);
498
+ }
499
+ lines.push("");
500
+ }
501
+ return lines.join("\n");
502
+ }
388
503
  var RRF_K = 10;
389
504
  function computeComposite(rankings, churn, top) {
390
505
  const totalDimensions = Object.keys(rankings).length;
@@ -756,7 +871,7 @@ function formatCompositeTable(output) {
756
871
 
757
872
  // src/cli.ts
758
873
  var program = new Command();
759
- program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("1.4.0");
874
+ program.name("obscene").description("Identify hotspot files \u2014 complex code that changes frequently").version("2.0.0");
760
875
  var REPORT_GUIDE = {
761
876
  complexity: "Cyclomatic complexity (branch/loop count). NOT a quality judgment \u2014 a 500-line parser will naturally score high. Compare density, not raw values.",
762
877
  complexityDensity: "Complexity per line of code. Normalizes for file size. >0.25 suggests dense logic worth reviewing; <0.10 is typical for straightforward code.",
@@ -812,10 +927,25 @@ addSharedOptions(
812
927
  exitWithError(err);
813
928
  }
814
929
  });
930
+ program.command("init").description("generate a starter .obsignore based on project structure").action(() => {
931
+ try {
932
+ runInit();
933
+ } catch (err) {
934
+ exitWithError(err);
935
+ }
936
+ });
815
937
  function resolveExcludes(cliExcludes) {
816
938
  return [...readIgnoreFile(), ...cliExcludes ?? []];
817
939
  }
940
+ function warnIfNoIgnoreFile() {
941
+ if (!existsSync(".obsignore") && !existsSync(".obsceneignore")) {
942
+ process.stderr.write(
943
+ "hint: no .obsignore found \u2014 run `obscene init` to generate one with recommended exclusions\n"
944
+ );
945
+ }
946
+ }
818
947
  function runReport(opts) {
948
+ warnIfNoIgnoreFile();
819
949
  const top = parseInt(opts.top, 10);
820
950
  const allExcludes = resolveExcludes(opts.exclude);
821
951
  const files = runScc(allExcludes);
@@ -848,6 +978,7 @@ function runReport(opts) {
848
978
  }
849
979
  }
850
980
  function runHotspots(opts) {
981
+ warnIfNoIgnoreFile();
851
982
  const top = parseInt(opts.top, 10);
852
983
  const months = parseInt(opts.months, 10);
853
984
  const allExcludes = resolveExcludes(opts.exclude);
@@ -887,6 +1018,7 @@ ${formatCompositeTable(composite)}
887
1018
  }
888
1019
  }
889
1020
  function runCoupling(opts) {
1021
+ warnIfNoIgnoreFile();
890
1022
  const top = parseInt(opts.top, 10);
891
1023
  const months = parseInt(opts.months, 10);
892
1024
  const minCochanges = parseInt(opts.minCochanges, 10);
@@ -929,6 +1061,38 @@ function runCoupling(opts) {
929
1061
  `);
930
1062
  }
931
1063
  }
1064
+ function runInit() {
1065
+ if (existsSync(".obsignore")) {
1066
+ throw new Error(
1067
+ ".obsignore already exists. Remove it first to regenerate."
1068
+ );
1069
+ }
1070
+ if (existsSync(".obsceneignore")) {
1071
+ throw new Error(
1072
+ ".obsceneignore already exists. Remove it first to regenerate."
1073
+ );
1074
+ }
1075
+ const detected = detectIgnorePatterns();
1076
+ const content = formatIgnoreFile(detected);
1077
+ writeFileSync(".obsignore", content);
1078
+ const universalCount = UNIVERSAL_IGNORE_GROUPS.reduce(
1079
+ (sum, g) => sum + g.patterns.length,
1080
+ 0
1081
+ );
1082
+ process.stderr.write(
1083
+ `Created .obsignore with ${universalCount} universal exclusions`
1084
+ );
1085
+ if (detected.length > 0) {
1086
+ process.stderr.write(` + ${detected.length} detected patterns:
1087
+ `);
1088
+ for (const p of detected) {
1089
+ process.stderr.write(` ${p.pattern.padEnd(20)} ${p.comment}
1090
+ `);
1091
+ }
1092
+ } else {
1093
+ process.stderr.write(" (no project-specific patterns detected)\n");
1094
+ }
1095
+ }
932
1096
  function exitWithError(err) {
933
1097
  const message = err instanceof Error ? err.message : String(err);
934
1098
  process.stderr.write(`Error: ${message}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/obscene",
3
- "version": "1.4.0",
3
+ "version": "2.0.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": {