prunify 0.1.2 → 0.1.5

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/dist/cli.js CHANGED
@@ -209,40 +209,12 @@ function buildGraph(files, getImports) {
209
209
  function findEntryPoints(rootDir, packageJson) {
210
210
  const entries = [
211
211
  ...resolveNextJsEntries(rootDir),
212
+ ...resolveFileBrowserEntries(rootDir),
212
213
  ...resolvePkgFieldEntries(rootDir, packageJson),
213
214
  ...resolveFallbackEntries(rootDir)
214
215
  ];
215
216
  return [...new Set(entries)];
216
217
  }
217
- function findRootFiles(graph) {
218
- const imported = /* @__PURE__ */ new Set();
219
- for (const deps of graph.values()) {
220
- for (const dep of deps) imported.add(dep);
221
- }
222
- const roots = [...graph.keys()].filter((f) => !imported.has(f));
223
- return roots.length > 0 ? roots : [...graph.keys()];
224
- }
225
- function runDFS(graph, entryPoints) {
226
- const visited = /* @__PURE__ */ new Set();
227
- const stack = [...entryPoints];
228
- let node;
229
- while ((node = stack.pop()) !== void 0) {
230
- if (visited.has(node)) continue;
231
- visited.add(node);
232
- for (const neighbor of graph.get(node) ?? []) {
233
- if (!visited.has(neighbor)) stack.push(neighbor);
234
- }
235
- }
236
- return visited;
237
- }
238
- function findDeadChains(graph, deadFiles) {
239
- const reverseGraph = buildReverseGraph(graph);
240
- const result = /* @__PURE__ */ new Map();
241
- for (const deadRoot of deadFiles) {
242
- result.set(deadRoot, dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph));
243
- }
244
- return result;
245
- }
246
218
  function detectCycles(graph) {
247
219
  const cycles = [];
248
220
  const seenKeys = /* @__PURE__ */ new Set();
@@ -261,12 +233,21 @@ function resolveNextJsEntries(rootDir) {
261
233
  const isNext = fs3.existsSync(path3.join(rootDir, "next.config.js")) || fs3.existsSync(path3.join(rootDir, "next.config.ts")) || fs3.existsSync(path3.join(rootDir, "next.config.mjs"));
262
234
  if (!isNext) return [];
263
235
  const entries = [];
264
- for (const dir of ["pages", "app"]) {
236
+ for (const dir of ["pages", "app", "src/pages", "src/app"]) {
265
237
  const dirPath = path3.join(rootDir, dir);
266
238
  if (fs3.existsSync(dirPath)) entries.push(...collectSourceFiles(dirPath));
267
239
  }
268
240
  return entries;
269
241
  }
242
+ function resolveFileBrowserEntries(rootDir) {
243
+ const PAGE_DIRS = ["src/pages", "src/routes", "src/views", "src/screens"];
244
+ const entries = [];
245
+ for (const rel of PAGE_DIRS) {
246
+ const dirPath = path3.join(rootDir, rel);
247
+ if (fs3.existsSync(dirPath)) entries.push(...collectSourceFiles(dirPath));
248
+ }
249
+ return entries;
250
+ }
270
251
  function resolvePkgFieldEntries(rootDir, packageJson) {
271
252
  const entries = [];
272
253
  for (const field of ["main", "module"]) {
@@ -343,27 +324,6 @@ function recordCycle(cycleStart, path10, acc) {
343
324
  acc.cycles.push(cycle);
344
325
  }
345
326
  }
346
- function dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph) {
347
- const chain = [];
348
- const visited = /* @__PURE__ */ new Set();
349
- const stack = [...graph.get(deadRoot) ?? []];
350
- let node;
351
- while ((node = stack.pop()) !== void 0) {
352
- if (visited.has(node) || node === deadRoot) continue;
353
- visited.add(node);
354
- if (deadFiles.has(node) || isOnlyImportedByDead(node, deadFiles, reverseGraph)) {
355
- chain.push(node);
356
- for (const next of graph.get(node) ?? []) {
357
- if (!visited.has(next)) stack.push(next);
358
- }
359
- }
360
- }
361
- return chain;
362
- }
363
- function isOnlyImportedByDead(file, deadFiles, reverseGraph) {
364
- const importers = reverseGraph.get(file) ?? /* @__PURE__ */ new Set();
365
- return importers.size === 0 || [...importers].every((imp) => deadFiles.has(imp));
366
- }
367
327
  function buildReverseGraph(graph) {
368
328
  const rev = /* @__PURE__ */ new Map();
369
329
  for (const [file] of graph) {
@@ -490,16 +450,101 @@ function ensureDir(dir) {
490
450
  }
491
451
 
492
452
  // src/modules/dead-code.ts
453
+ var FRAMEWORK_FILE_PATTERNS = [
454
+ /^next\.config\.(js|ts|mjs|cjs)$/,
455
+ /^middleware\.(ts|js)$/,
456
+ /^instrumentation\.(ts|js)$/,
457
+ /^tailwind\.config\.(js|ts|mjs|cjs)$/,
458
+ /^postcss\.config\.(js|ts|mjs|cjs)$/,
459
+ /^jest\.config\.(js|ts|mjs|cjs)$/,
460
+ /^vitest\.config\.(js|ts|mjs|cjs)$/,
461
+ /^vite\.config\.(js|ts|mjs|cjs)$/,
462
+ /^webpack\.config\.(js|ts|mjs|cjs)$/,
463
+ /^babel\.config\.(js|ts|cjs|mjs|json)$/,
464
+ /^\.babelrc\.(js|cjs)$/,
465
+ /^\.eslintrc\.(js|cjs)$/,
466
+ /^eslint\.config\.(js|ts|mjs|cjs)$/,
467
+ /^prettier\.config\.(js|ts|mjs|cjs)$/,
468
+ /^tsup\.config\.(ts|js)$/,
469
+ /^rollup\.config\.(js|ts|mjs)$/,
470
+ /^esbuild\.config\.(js|ts|mjs)$/,
471
+ /^commitlint\.config\.(js|ts)$/,
472
+ /^lint-staged\.config\.(js|ts|mjs|cjs)$/,
473
+ /^sentry\.(client|server|edge)\.config\.(ts|js)$/
474
+ ];
475
+ function isFrameworkFile(filePath, rootDir) {
476
+ const rel = path5.relative(rootDir, filePath);
477
+ const basename = path5.basename(rel);
478
+ return FRAMEWORK_FILE_PATTERNS.some((re) => re.test(basename));
479
+ }
493
480
  function runDeadCodeModule(project, graph, entryPoints, rootDir) {
494
481
  const allFiles = [...graph.keys()];
495
- const effectiveEntries = entryPoints.length > 0 ? entryPoints : findRootFiles(graph);
496
- const liveFiles = runDFS(graph, effectiveEntries);
497
- const deadFiles = allFiles.filter((f) => !liveFiles.has(f));
498
- const deadSet = new Set(deadFiles);
499
- const chains = findDeadChains(graph, deadSet);
482
+ const entrySet = new Set(entryPoints);
483
+ const reverseGraph = buildReverseGraph(graph);
484
+ const excludedFiles = /* @__PURE__ */ new Set();
485
+ for (const file of allFiles) {
486
+ if (isFrameworkFile(file, rootDir)) excludedFiles.add(file);
487
+ }
488
+ const safeToDelete = allFiles.filter((f) => {
489
+ if (entrySet.has(f) || excludedFiles.has(f)) return false;
490
+ const importers = reverseGraph.get(f);
491
+ return !importers || importers.size === 0;
492
+ });
493
+ const deadSet = new Set(safeToDelete);
494
+ let changed = true;
495
+ while (changed) {
496
+ changed = false;
497
+ for (const file of allFiles) {
498
+ if (deadSet.has(file) || entrySet.has(file) || excludedFiles.has(file)) continue;
499
+ const importers = reverseGraph.get(file);
500
+ if (!importers || importers.size === 0) continue;
501
+ if ([...importers].every((imp) => deadSet.has(imp))) {
502
+ deadSet.add(file);
503
+ changed = true;
504
+ }
505
+ }
506
+ }
507
+ const transitivelyDead = [...deadSet].filter((f) => !safeToDelete.includes(f));
508
+ const chains = /* @__PURE__ */ new Map();
509
+ for (const root of safeToDelete) {
510
+ const chain = collectTransitiveChain(root, graph, deadSet);
511
+ chains.set(root, chain);
512
+ }
513
+ const liveFiles = new Set(allFiles.filter((f) => !deadSet.has(f)));
500
514
  const deadExports = findDeadExports(project, liveFiles);
501
- const report = buildDeadCodeReport(deadFiles, chains, deadExports, rootDir);
502
- return { deadFiles, liveFiles, chains, deadExports, report };
515
+ const report = buildDeadCodeReport(
516
+ safeToDelete,
517
+ transitivelyDead,
518
+ chains,
519
+ deadExports,
520
+ rootDir
521
+ );
522
+ return {
523
+ safeToDelete,
524
+ transitivelyDead,
525
+ deadFiles: [...deadSet],
526
+ liveFiles,
527
+ chains,
528
+ deadExports,
529
+ report
530
+ };
531
+ }
532
+ function collectTransitiveChain(root, graph, deadSet) {
533
+ const chain = [];
534
+ const visited = /* @__PURE__ */ new Set();
535
+ const stack = [...graph.get(root) ?? []];
536
+ let node;
537
+ while ((node = stack.pop()) !== void 0) {
538
+ if (visited.has(node) || node === root) continue;
539
+ visited.add(node);
540
+ if (deadSet.has(node)) {
541
+ chain.push(node);
542
+ for (const next of graph.get(node) ?? []) {
543
+ if (!visited.has(next)) stack.push(next);
544
+ }
545
+ }
546
+ }
547
+ return chain;
503
548
  }
504
549
  function findDeadExports(project, liveFiles) {
505
550
  const importedNames = buildImportedNameMap(project, liveFiles);
@@ -532,51 +577,61 @@ function getFileSize(filePath) {
532
577
  return 0;
533
578
  }
534
579
  }
535
- function buildDeadCodeReport(deadFiles, chains, deadExports, rootDir) {
580
+ function buildDeadCodeReport(safeToDelete, transitivelyDead, chains, deadExports, rootDir) {
536
581
  const rel = (p) => path5.relative(rootDir, p).replaceAll("\\", "/");
537
- const totalBytes = deadFiles.reduce((sum, f) => {
538
- const chain = chains.get(f) ?? [];
539
- return sum + getFileSize(f) + chain.reduce((s, c) => s + getFileSize(c), 0);
540
- }, 0);
582
+ const allDeadFiles = [...safeToDelete, ...transitivelyDead];
583
+ const totalBytes = allDeadFiles.reduce((sum, f) => sum + getFileSize(f), 0);
541
584
  const totalKb = (totalBytes / 1024).toFixed(1);
542
585
  const lines = [
543
586
  "========================================",
544
587
  " DEAD CODE REPORT",
545
- ` Dead files : ${deadFiles.length}`,
546
- ` Dead exports: ${deadExports.length}`,
547
- ` Recoverable : ~${totalKb} KB`,
588
+ ` Safe to delete : ${safeToDelete.length}`,
589
+ ` Transitively dead : ${transitivelyDead.length}`,
590
+ ` Dead exports : ${deadExports.length}`,
591
+ ` Recoverable : ~${totalKb} KB`,
548
592
  "========================================",
549
593
  ""
550
594
  ];
551
- if (deadFiles.length > 0) {
552
- lines.push("\u2500\u2500 DEAD FILES \u2500\u2500", "");
553
- for (const filePath of deadFiles) {
595
+ if (safeToDelete.length > 0) {
596
+ lines.push(
597
+ "\u2500\u2500 SAFE TO DELETE \u2500\u2500",
598
+ "(These files are not imported by any other file in the codebase)",
599
+ ""
600
+ );
601
+ for (const filePath of safeToDelete) {
554
602
  const chain = chains.get(filePath) ?? [];
555
- const allFiles = [filePath, ...chain];
556
- const sizeBytes = allFiles.reduce((s, f) => s + getFileSize(f), 0);
557
- const sizeKb = (sizeBytes / 1024).toFixed(1);
558
- const chainStr = chain.length > 0 ? [rel(filePath), ...chain.map(rel)].join(" \u2192 ") : rel(filePath);
559
- const plural = allFiles.length === 1 ? "" : "s";
560
- lines.push(
561
- `DEAD FILE \u2014 ${rel(filePath)}`,
562
- `Reason: Not imported anywhere in the codebase`,
563
- `Chain: ${chainStr}`,
564
- `Size: ~${sizeKb} KB removable across ${allFiles.length} file${plural}`,
565
- `Action: Safe to delete all ${allFiles.length} file${plural}`,
566
- ""
567
- );
603
+ const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
604
+ lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
605
+ if (chain.length > 0) {
606
+ lines.push(` \u2514\u2500 also makes dead: ${chain.map(rel).join(", ")}`);
607
+ }
608
+ }
609
+ lines.push("");
610
+ }
611
+ if (transitivelyDead.length > 0) {
612
+ lines.push(
613
+ "\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500",
614
+ "(These files are only imported by dead files \u2014 they become orphaned too)",
615
+ ""
616
+ );
617
+ for (const filePath of transitivelyDead) {
618
+ const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
619
+ lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
568
620
  }
621
+ lines.push("");
569
622
  }
570
623
  if (deadExports.length > 0) {
571
- lines.push("\u2500\u2500 DEAD EXPORTS \u2500\u2500", "");
624
+ lines.push(
625
+ "\u2500\u2500 DEAD EXPORTS \u2500\u2500",
626
+ "(Exported but never imported by any other file)",
627
+ ""
628
+ );
572
629
  for (const entry of deadExports) {
573
630
  lines.push(
574
- `DEAD EXPORT \u2014 ${rel(entry.filePath)} \u2192 ${entry.exportName}() [line ${entry.line}]`,
575
- `Reason: Exported but never imported`,
576
- `Action: Remove the export (file itself is still live)`,
577
- ""
631
+ ` ${rel(entry.filePath)} \u2192 ${entry.exportName} [line ${entry.line}]`
578
632
  );
579
633
  }
634
+ lines.push("");
580
635
  }
581
636
  return lines.join("\n");
582
637
  }
@@ -593,7 +648,8 @@ async function runDeadCode(dir, opts) {
593
648
  const entries = findEntryPoints(dir, packageJson);
594
649
  const result = runDeadCodeModule(project, graph, entries, dir);
595
650
  const dead = [
596
- ...result.deadFiles.map((f) => ({ file: f, exportName: "(entire file)" })),
651
+ ...result.safeToDelete.map((f) => ({ file: f, exportName: "(entire file \u2014 safe to delete)" })),
652
+ ...result.transitivelyDead.map((f) => ({ file: f, exportName: "(entire file \u2014 transitively dead)" })),
597
653
  ...result.deadExports.map((e) => ({ file: e.filePath, exportName: e.exportName }))
598
654
  ];
599
655
  spinner.succeed(chalk.green(`Dead code scan complete \u2014 ${dead.length} item(s) found`));
@@ -669,7 +725,11 @@ function writeDeadOutput(result, opts) {
669
725
  if (!opts.output) return;
670
726
  if (opts.json) {
671
727
  writeJson(
672
- { deadFiles: result.deadFiles, deadExports: result.deadExports },
728
+ {
729
+ safeToDelete: result.safeToDelete,
730
+ transitivelyDead: result.transitivelyDead,
731
+ deadExports: result.deadExports
732
+ },
673
733
  opts.output
674
734
  );
675
735
  console.log(chalk.cyan(` JSON written to ${opts.output}`));
@@ -678,16 +738,21 @@ function writeDeadOutput(result, opts) {
678
738
  writeMarkdown(
679
739
  {
680
740
  title: "Dead Code Report",
681
- summary: `${result.deadFiles.length} dead file(s), ${result.deadExports.length} dead export(s)`,
741
+ summary: `${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`,
682
742
  sections: [
683
743
  {
684
- title: "Dead Files",
744
+ title: "Safe to Delete",
685
745
  headers: ["File", "Chain"],
686
- rows: result.deadFiles.map((f) => [
746
+ rows: result.safeToDelete.map((f) => [
687
747
  f,
688
748
  (result.chains.get(f) ?? []).join(" \u2192 ") || "\u2014"
689
749
  ])
690
750
  },
751
+ {
752
+ title: "Transitively Dead",
753
+ headers: ["File"],
754
+ rows: result.transitivelyDead.map((f) => [f])
755
+ },
691
756
  {
692
757
  title: "Dead Exports",
693
758
  headers: ["File", "Export", "Line"],
@@ -716,17 +781,30 @@ import {
716
781
  } from "ts-morph";
717
782
 
718
783
  // src/modules/dupe-finder.ts
784
+ var MIN_BLOCK_CHARS = 120;
719
785
  async function runDupeFinder(dir, opts) {
720
- const minLines = parseInt(opts.minLines ?? "5", 10);
786
+ const minLines = parseInt(opts.minLines ?? "15", 10);
721
787
  const spinner = ora3(chalk2.cyan(`Scanning for duplicate blocks (\u2265${minLines} lines)\u2026`)).start();
722
788
  try {
723
- const files = glob(dir, ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], ["node_modules", "dist"]);
789
+ const files = glob(dir, ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], [
790
+ "node_modules",
791
+ "dist",
792
+ ".next",
793
+ "coverage",
794
+ "**/*.test.ts",
795
+ "**/*.test.tsx",
796
+ "**/*.spec.ts",
797
+ "**/*.spec.tsx",
798
+ "**/*.d.ts"
799
+ ]);
724
800
  const blockMap = /* @__PURE__ */ new Map();
725
801
  for (const filePath of files) {
726
802
  const content = fs6.readFileSync(filePath, "utf-8");
727
803
  const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
728
804
  for (let i = 0; i <= lines.length - minLines; i++) {
729
805
  const block = lines.slice(i, i + minLines).join("\n");
806
+ const meaningfulChars = block.replace(/[\s{}();,]/g, "").length;
807
+ if (meaningfulChars < MIN_BLOCK_CHARS) continue;
730
808
  if (!blockMap.has(block)) blockMap.set(block, []);
731
809
  blockMap.get(block).push({ file: filePath, startLine: i + 1 });
732
810
  }
@@ -741,6 +819,8 @@ async function runDupeFinder(dir, opts) {
741
819
  });
742
820
  }
743
821
  }
822
+ dupes.sort((a, b) => b.occurrences.length - a.occurrences.length);
823
+ if (dupes.length > 200) dupes.splice(200);
744
824
  spinner.succeed(chalk2.green(`Duplicate scan complete \u2014 ${dupes.length} duplicate block(s) found`));
745
825
  if (dupes.length === 0) {
746
826
  console.log(chalk2.green(" No duplicate blocks detected."));
@@ -1253,12 +1333,19 @@ async function main(opts) {
1253
1333
  if (modules.includes("dead-code")) {
1254
1334
  const spinner = createSpinner(chalk7.cyan("Analysing dead code\u2026"));
1255
1335
  const result = runDeadCodeModule(project, graph, entryPoints, rootDir);
1256
- deadFileCount = result.deadFiles.length + result.deadExports.length;
1257
- deadFilePaths.push(...result.deadFiles);
1258
- spinner.succeed(chalk7.green(`Dead code analysis complete \u2014 ${deadFileCount} item(s) found`));
1336
+ deadFileCount = result.safeToDelete.length + result.transitivelyDead.length + result.deadExports.length;
1337
+ deadFilePaths.push(...result.safeToDelete, ...result.transitivelyDead);
1338
+ spinner.succeed(
1339
+ chalk7.green(
1340
+ `Dead code analysis complete \u2014 ${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`
1341
+ )
1342
+ );
1259
1343
  if (result.report) {
1260
1344
  deadReportFile = "dead-code.txt";
1261
- writeReport(reportsDir, deadReportFile, result.report);
1345
+ const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
1346
+
1347
+ `;
1348
+ writeReport(reportsDir, deadReportFile, banner + result.report);
1262
1349
  }
1263
1350
  }
1264
1351
  if (modules.includes("dupes")) {
@@ -1274,8 +1361,28 @@ async function main(opts) {
1274
1361
  spinner.succeed(chalk7.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
1275
1362
  if (circularCount > 0) {
1276
1363
  circularReportFile = "circular.txt";
1277
- const cycleText = cycles.map((c, i) => `Cycle ${i + 1}: ${c.join(" \u2192 ")}`).join("\n");
1278
- writeReport(reportsDir, circularReportFile, cycleText);
1364
+ const rel = (p) => path9.relative(rootDir, p).replaceAll("\\", "/");
1365
+ const cycleLines = [
1366
+ "========================================",
1367
+ " CIRCULAR DEPENDENCIES",
1368
+ ` Cycles found: ${cycles.length}`,
1369
+ "========================================",
1370
+ ""
1371
+ ];
1372
+ for (let i = 0; i < cycles.length; i++) {
1373
+ const cycle = cycles[i];
1374
+ cycleLines.push(`Cycle ${i + 1} (${cycle.length} files):`);
1375
+ for (let j = 0; j < cycle.length; j++) {
1376
+ const arrow = j < cycle.length - 1 ? " \u2192 " : " \u2192 ";
1377
+ cycleLines.push(` ${rel(cycle[j])}`);
1378
+ }
1379
+ cycleLines.push(` \u21BB ${rel(cycle[0])} (back to start)`);
1380
+ cycleLines.push("");
1381
+ }
1382
+ const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
1383
+
1384
+ `;
1385
+ writeReport(reportsDir, circularReportFile, banner + cycleLines.join("\n"));
1279
1386
  }
1280
1387
  }
1281
1388
  if (modules.includes("deps")) {
@@ -1308,10 +1415,10 @@ async function main(opts) {
1308
1415
  });
1309
1416
  const fmt = (n) => n > 0 ? chalk7.yellow(String(n)) : chalk7.green("0");
1310
1417
  table.push(
1311
- ["Dead Files / Exports", fmt(deadFileCount), deadReportFile || "\u2014"],
1418
+ ["Dead Code (files + exports)", fmt(deadFileCount), deadReportFile || "\u2014"],
1419
+ ["Circular Dependencies", fmt(circularCount), circularReportFile || "\u2014"],
1312
1420
  ["Duplicate Clusters", fmt(dupeCount), dupesReportFile || "\u2014"],
1313
1421
  ["Unused Packages", fmt(unusedPkgCount), depsReportFile || "\u2014"],
1314
- ["Circular Deps", fmt(circularCount), circularReportFile || "\u2014"],
1315
1422
  ["Unused Assets", fmt(unusedAssetCount), assetsReportFile || "\u2014"]
1316
1423
  );
1317
1424
  console.log(table.toString());