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.cjs CHANGED
@@ -232,40 +232,12 @@ function buildGraph(files, getImports) {
232
232
  function findEntryPoints(rootDir, packageJson) {
233
233
  const entries = [
234
234
  ...resolveNextJsEntries(rootDir),
235
+ ...resolveFileBrowserEntries(rootDir),
235
236
  ...resolvePkgFieldEntries(rootDir, packageJson),
236
237
  ...resolveFallbackEntries(rootDir)
237
238
  ];
238
239
  return [...new Set(entries)];
239
240
  }
240
- function findRootFiles(graph) {
241
- const imported = /* @__PURE__ */ new Set();
242
- for (const deps of graph.values()) {
243
- for (const dep of deps) imported.add(dep);
244
- }
245
- const roots = [...graph.keys()].filter((f) => !imported.has(f));
246
- return roots.length > 0 ? roots : [...graph.keys()];
247
- }
248
- function runDFS(graph, entryPoints) {
249
- const visited = /* @__PURE__ */ new Set();
250
- const stack = [...entryPoints];
251
- let node;
252
- while ((node = stack.pop()) !== void 0) {
253
- if (visited.has(node)) continue;
254
- visited.add(node);
255
- for (const neighbor of graph.get(node) ?? []) {
256
- if (!visited.has(neighbor)) stack.push(neighbor);
257
- }
258
- }
259
- return visited;
260
- }
261
- function findDeadChains(graph, deadFiles) {
262
- const reverseGraph = buildReverseGraph(graph);
263
- const result = /* @__PURE__ */ new Map();
264
- for (const deadRoot of deadFiles) {
265
- result.set(deadRoot, dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph));
266
- }
267
- return result;
268
- }
269
241
  function detectCycles(graph) {
270
242
  const cycles = [];
271
243
  const seenKeys = /* @__PURE__ */ new Set();
@@ -284,12 +256,21 @@ function resolveNextJsEntries(rootDir) {
284
256
  const isNext = import_node_fs3.default.existsSync(import_node_path3.default.join(rootDir, "next.config.js")) || import_node_fs3.default.existsSync(import_node_path3.default.join(rootDir, "next.config.ts")) || import_node_fs3.default.existsSync(import_node_path3.default.join(rootDir, "next.config.mjs"));
285
257
  if (!isNext) return [];
286
258
  const entries = [];
287
- for (const dir of ["pages", "app"]) {
259
+ for (const dir of ["pages", "app", "src/pages", "src/app"]) {
288
260
  const dirPath = import_node_path3.default.join(rootDir, dir);
289
261
  if (import_node_fs3.default.existsSync(dirPath)) entries.push(...collectSourceFiles(dirPath));
290
262
  }
291
263
  return entries;
292
264
  }
265
+ function resolveFileBrowserEntries(rootDir) {
266
+ const PAGE_DIRS = ["src/pages", "src/routes", "src/views", "src/screens"];
267
+ const entries = [];
268
+ for (const rel of PAGE_DIRS) {
269
+ const dirPath = import_node_path3.default.join(rootDir, rel);
270
+ if (import_node_fs3.default.existsSync(dirPath)) entries.push(...collectSourceFiles(dirPath));
271
+ }
272
+ return entries;
273
+ }
293
274
  function resolvePkgFieldEntries(rootDir, packageJson) {
294
275
  const entries = [];
295
276
  for (const field of ["main", "module"]) {
@@ -366,27 +347,6 @@ function recordCycle(cycleStart, path10, acc) {
366
347
  acc.cycles.push(cycle);
367
348
  }
368
349
  }
369
- function dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph) {
370
- const chain = [];
371
- const visited = /* @__PURE__ */ new Set();
372
- const stack = [...graph.get(deadRoot) ?? []];
373
- let node;
374
- while ((node = stack.pop()) !== void 0) {
375
- if (visited.has(node) || node === deadRoot) continue;
376
- visited.add(node);
377
- if (deadFiles.has(node) || isOnlyImportedByDead(node, deadFiles, reverseGraph)) {
378
- chain.push(node);
379
- for (const next of graph.get(node) ?? []) {
380
- if (!visited.has(next)) stack.push(next);
381
- }
382
- }
383
- }
384
- return chain;
385
- }
386
- function isOnlyImportedByDead(file, deadFiles, reverseGraph) {
387
- const importers = reverseGraph.get(file) ?? /* @__PURE__ */ new Set();
388
- return importers.size === 0 || [...importers].every((imp) => deadFiles.has(imp));
389
- }
390
350
  function buildReverseGraph(graph) {
391
351
  const rev = /* @__PURE__ */ new Map();
392
352
  for (const [file] of graph) {
@@ -513,16 +473,101 @@ function ensureDir(dir) {
513
473
  }
514
474
 
515
475
  // src/modules/dead-code.ts
476
+ var FRAMEWORK_FILE_PATTERNS = [
477
+ /^next\.config\.(js|ts|mjs|cjs)$/,
478
+ /^middleware\.(ts|js)$/,
479
+ /^instrumentation\.(ts|js)$/,
480
+ /^tailwind\.config\.(js|ts|mjs|cjs)$/,
481
+ /^postcss\.config\.(js|ts|mjs|cjs)$/,
482
+ /^jest\.config\.(js|ts|mjs|cjs)$/,
483
+ /^vitest\.config\.(js|ts|mjs|cjs)$/,
484
+ /^vite\.config\.(js|ts|mjs|cjs)$/,
485
+ /^webpack\.config\.(js|ts|mjs|cjs)$/,
486
+ /^babel\.config\.(js|ts|cjs|mjs|json)$/,
487
+ /^\.babelrc\.(js|cjs)$/,
488
+ /^\.eslintrc\.(js|cjs)$/,
489
+ /^eslint\.config\.(js|ts|mjs|cjs)$/,
490
+ /^prettier\.config\.(js|ts|mjs|cjs)$/,
491
+ /^tsup\.config\.(ts|js)$/,
492
+ /^rollup\.config\.(js|ts|mjs)$/,
493
+ /^esbuild\.config\.(js|ts|mjs)$/,
494
+ /^commitlint\.config\.(js|ts)$/,
495
+ /^lint-staged\.config\.(js|ts|mjs|cjs)$/,
496
+ /^sentry\.(client|server|edge)\.config\.(ts|js)$/
497
+ ];
498
+ function isFrameworkFile(filePath, rootDir) {
499
+ const rel = import_node_path5.default.relative(rootDir, filePath);
500
+ const basename = import_node_path5.default.basename(rel);
501
+ return FRAMEWORK_FILE_PATTERNS.some((re) => re.test(basename));
502
+ }
516
503
  function runDeadCodeModule(project, graph, entryPoints, rootDir) {
517
504
  const allFiles = [...graph.keys()];
518
- const effectiveEntries = entryPoints.length > 0 ? entryPoints : findRootFiles(graph);
519
- const liveFiles = runDFS(graph, effectiveEntries);
520
- const deadFiles = allFiles.filter((f) => !liveFiles.has(f));
521
- const deadSet = new Set(deadFiles);
522
- const chains = findDeadChains(graph, deadSet);
505
+ const entrySet = new Set(entryPoints);
506
+ const reverseGraph = buildReverseGraph(graph);
507
+ const excludedFiles = /* @__PURE__ */ new Set();
508
+ for (const file of allFiles) {
509
+ if (isFrameworkFile(file, rootDir)) excludedFiles.add(file);
510
+ }
511
+ const safeToDelete = allFiles.filter((f) => {
512
+ if (entrySet.has(f) || excludedFiles.has(f)) return false;
513
+ const importers = reverseGraph.get(f);
514
+ return !importers || importers.size === 0;
515
+ });
516
+ const deadSet = new Set(safeToDelete);
517
+ let changed = true;
518
+ while (changed) {
519
+ changed = false;
520
+ for (const file of allFiles) {
521
+ if (deadSet.has(file) || entrySet.has(file) || excludedFiles.has(file)) continue;
522
+ const importers = reverseGraph.get(file);
523
+ if (!importers || importers.size === 0) continue;
524
+ if ([...importers].every((imp) => deadSet.has(imp))) {
525
+ deadSet.add(file);
526
+ changed = true;
527
+ }
528
+ }
529
+ }
530
+ const transitivelyDead = [...deadSet].filter((f) => !safeToDelete.includes(f));
531
+ const chains = /* @__PURE__ */ new Map();
532
+ for (const root of safeToDelete) {
533
+ const chain = collectTransitiveChain(root, graph, deadSet);
534
+ chains.set(root, chain);
535
+ }
536
+ const liveFiles = new Set(allFiles.filter((f) => !deadSet.has(f)));
523
537
  const deadExports = findDeadExports(project, liveFiles);
524
- const report = buildDeadCodeReport(deadFiles, chains, deadExports, rootDir);
525
- return { deadFiles, liveFiles, chains, deadExports, report };
538
+ const report = buildDeadCodeReport(
539
+ safeToDelete,
540
+ transitivelyDead,
541
+ chains,
542
+ deadExports,
543
+ rootDir
544
+ );
545
+ return {
546
+ safeToDelete,
547
+ transitivelyDead,
548
+ deadFiles: [...deadSet],
549
+ liveFiles,
550
+ chains,
551
+ deadExports,
552
+ report
553
+ };
554
+ }
555
+ function collectTransitiveChain(root, graph, deadSet) {
556
+ const chain = [];
557
+ const visited = /* @__PURE__ */ new Set();
558
+ const stack = [...graph.get(root) ?? []];
559
+ let node;
560
+ while ((node = stack.pop()) !== void 0) {
561
+ if (visited.has(node) || node === root) continue;
562
+ visited.add(node);
563
+ if (deadSet.has(node)) {
564
+ chain.push(node);
565
+ for (const next of graph.get(node) ?? []) {
566
+ if (!visited.has(next)) stack.push(next);
567
+ }
568
+ }
569
+ }
570
+ return chain;
526
571
  }
527
572
  function findDeadExports(project, liveFiles) {
528
573
  const importedNames = buildImportedNameMap(project, liveFiles);
@@ -555,51 +600,61 @@ function getFileSize(filePath) {
555
600
  return 0;
556
601
  }
557
602
  }
558
- function buildDeadCodeReport(deadFiles, chains, deadExports, rootDir) {
603
+ function buildDeadCodeReport(safeToDelete, transitivelyDead, chains, deadExports, rootDir) {
559
604
  const rel = (p) => import_node_path5.default.relative(rootDir, p).replaceAll("\\", "/");
560
- const totalBytes = deadFiles.reduce((sum, f) => {
561
- const chain = chains.get(f) ?? [];
562
- return sum + getFileSize(f) + chain.reduce((s, c) => s + getFileSize(c), 0);
563
- }, 0);
605
+ const allDeadFiles = [...safeToDelete, ...transitivelyDead];
606
+ const totalBytes = allDeadFiles.reduce((sum, f) => sum + getFileSize(f), 0);
564
607
  const totalKb = (totalBytes / 1024).toFixed(1);
565
608
  const lines = [
566
609
  "========================================",
567
610
  " DEAD CODE REPORT",
568
- ` Dead files : ${deadFiles.length}`,
569
- ` Dead exports: ${deadExports.length}`,
570
- ` Recoverable : ~${totalKb} KB`,
611
+ ` Safe to delete : ${safeToDelete.length}`,
612
+ ` Transitively dead : ${transitivelyDead.length}`,
613
+ ` Dead exports : ${deadExports.length}`,
614
+ ` Recoverable : ~${totalKb} KB`,
571
615
  "========================================",
572
616
  ""
573
617
  ];
574
- if (deadFiles.length > 0) {
575
- lines.push("\u2500\u2500 DEAD FILES \u2500\u2500", "");
576
- for (const filePath of deadFiles) {
618
+ if (safeToDelete.length > 0) {
619
+ lines.push(
620
+ "\u2500\u2500 SAFE TO DELETE \u2500\u2500",
621
+ "(These files are not imported by any other file in the codebase)",
622
+ ""
623
+ );
624
+ for (const filePath of safeToDelete) {
577
625
  const chain = chains.get(filePath) ?? [];
578
- const allFiles = [filePath, ...chain];
579
- const sizeBytes = allFiles.reduce((s, f) => s + getFileSize(f), 0);
580
- const sizeKb = (sizeBytes / 1024).toFixed(1);
581
- const chainStr = chain.length > 0 ? [rel(filePath), ...chain.map(rel)].join(" \u2192 ") : rel(filePath);
582
- const plural = allFiles.length === 1 ? "" : "s";
583
- lines.push(
584
- `DEAD FILE \u2014 ${rel(filePath)}`,
585
- `Reason: Not imported anywhere in the codebase`,
586
- `Chain: ${chainStr}`,
587
- `Size: ~${sizeKb} KB removable across ${allFiles.length} file${plural}`,
588
- `Action: Safe to delete all ${allFiles.length} file${plural}`,
589
- ""
590
- );
626
+ const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
627
+ lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
628
+ if (chain.length > 0) {
629
+ lines.push(` \u2514\u2500 also makes dead: ${chain.map(rel).join(", ")}`);
630
+ }
631
+ }
632
+ lines.push("");
633
+ }
634
+ if (transitivelyDead.length > 0) {
635
+ lines.push(
636
+ "\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500",
637
+ "(These files are only imported by dead files \u2014 they become orphaned too)",
638
+ ""
639
+ );
640
+ for (const filePath of transitivelyDead) {
641
+ const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
642
+ lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
591
643
  }
644
+ lines.push("");
592
645
  }
593
646
  if (deadExports.length > 0) {
594
- lines.push("\u2500\u2500 DEAD EXPORTS \u2500\u2500", "");
647
+ lines.push(
648
+ "\u2500\u2500 DEAD EXPORTS \u2500\u2500",
649
+ "(Exported but never imported by any other file)",
650
+ ""
651
+ );
595
652
  for (const entry of deadExports) {
596
653
  lines.push(
597
- `DEAD EXPORT \u2014 ${rel(entry.filePath)} \u2192 ${entry.exportName}() [line ${entry.line}]`,
598
- `Reason: Exported but never imported`,
599
- `Action: Remove the export (file itself is still live)`,
600
- ""
654
+ ` ${rel(entry.filePath)} \u2192 ${entry.exportName} [line ${entry.line}]`
601
655
  );
602
656
  }
657
+ lines.push("");
603
658
  }
604
659
  return lines.join("\n");
605
660
  }
@@ -616,7 +671,8 @@ async function runDeadCode(dir, opts) {
616
671
  const entries = findEntryPoints(dir, packageJson);
617
672
  const result = runDeadCodeModule(project, graph, entries, dir);
618
673
  const dead = [
619
- ...result.deadFiles.map((f) => ({ file: f, exportName: "(entire file)" })),
674
+ ...result.safeToDelete.map((f) => ({ file: f, exportName: "(entire file \u2014 safe to delete)" })),
675
+ ...result.transitivelyDead.map((f) => ({ file: f, exportName: "(entire file \u2014 transitively dead)" })),
620
676
  ...result.deadExports.map((e) => ({ file: e.filePath, exportName: e.exportName }))
621
677
  ];
622
678
  spinner.succeed(import_chalk.default.green(`Dead code scan complete \u2014 ${dead.length} item(s) found`));
@@ -692,7 +748,11 @@ function writeDeadOutput(result, opts) {
692
748
  if (!opts.output) return;
693
749
  if (opts.json) {
694
750
  writeJson(
695
- { deadFiles: result.deadFiles, deadExports: result.deadExports },
751
+ {
752
+ safeToDelete: result.safeToDelete,
753
+ transitivelyDead: result.transitivelyDead,
754
+ deadExports: result.deadExports
755
+ },
696
756
  opts.output
697
757
  );
698
758
  console.log(import_chalk.default.cyan(` JSON written to ${opts.output}`));
@@ -701,16 +761,21 @@ function writeDeadOutput(result, opts) {
701
761
  writeMarkdown(
702
762
  {
703
763
  title: "Dead Code Report",
704
- summary: `${result.deadFiles.length} dead file(s), ${result.deadExports.length} dead export(s)`,
764
+ summary: `${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`,
705
765
  sections: [
706
766
  {
707
- title: "Dead Files",
767
+ title: "Safe to Delete",
708
768
  headers: ["File", "Chain"],
709
- rows: result.deadFiles.map((f) => [
769
+ rows: result.safeToDelete.map((f) => [
710
770
  f,
711
771
  (result.chains.get(f) ?? []).join(" \u2192 ") || "\u2014"
712
772
  ])
713
773
  },
774
+ {
775
+ title: "Transitively Dead",
776
+ headers: ["File"],
777
+ rows: result.transitivelyDead.map((f) => [f])
778
+ },
714
779
  {
715
780
  title: "Dead Exports",
716
781
  headers: ["File", "Export", "Line"],
@@ -736,17 +801,30 @@ var import_node_crypto = __toESM(require("crypto"), 1);
736
801
  var import_ts_morph3 = require("ts-morph");
737
802
 
738
803
  // src/modules/dupe-finder.ts
804
+ var MIN_BLOCK_CHARS = 120;
739
805
  async function runDupeFinder(dir, opts) {
740
- const minLines = parseInt(opts.minLines ?? "5", 10);
806
+ const minLines = parseInt(opts.minLines ?? "15", 10);
741
807
  const spinner = (0, import_ora3.default)(import_chalk2.default.cyan(`Scanning for duplicate blocks (\u2265${minLines} lines)\u2026`)).start();
742
808
  try {
743
- const files = glob(dir, ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], ["node_modules", "dist"]);
809
+ const files = glob(dir, ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], [
810
+ "node_modules",
811
+ "dist",
812
+ ".next",
813
+ "coverage",
814
+ "**/*.test.ts",
815
+ "**/*.test.tsx",
816
+ "**/*.spec.ts",
817
+ "**/*.spec.tsx",
818
+ "**/*.d.ts"
819
+ ]);
744
820
  const blockMap = /* @__PURE__ */ new Map();
745
821
  for (const filePath of files) {
746
822
  const content = import_node_fs6.default.readFileSync(filePath, "utf-8");
747
823
  const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
748
824
  for (let i = 0; i <= lines.length - minLines; i++) {
749
825
  const block = lines.slice(i, i + minLines).join("\n");
826
+ const meaningfulChars = block.replace(/[\s{}();,]/g, "").length;
827
+ if (meaningfulChars < MIN_BLOCK_CHARS) continue;
750
828
  if (!blockMap.has(block)) blockMap.set(block, []);
751
829
  blockMap.get(block).push({ file: filePath, startLine: i + 1 });
752
830
  }
@@ -761,6 +839,8 @@ async function runDupeFinder(dir, opts) {
761
839
  });
762
840
  }
763
841
  }
842
+ dupes.sort((a, b) => b.occurrences.length - a.occurrences.length);
843
+ if (dupes.length > 200) dupes.splice(200);
764
844
  spinner.succeed(import_chalk2.default.green(`Duplicate scan complete \u2014 ${dupes.length} duplicate block(s) found`));
765
845
  if (dupes.length === 0) {
766
846
  console.log(import_chalk2.default.green(" No duplicate blocks detected."));
@@ -1274,12 +1354,19 @@ async function main(opts) {
1274
1354
  if (modules.includes("dead-code")) {
1275
1355
  const spinner = createSpinner(import_chalk7.default.cyan("Analysing dead code\u2026"));
1276
1356
  const result = runDeadCodeModule(project, graph, entryPoints, rootDir);
1277
- deadFileCount = result.deadFiles.length + result.deadExports.length;
1278
- deadFilePaths.push(...result.deadFiles);
1279
- spinner.succeed(import_chalk7.default.green(`Dead code analysis complete \u2014 ${deadFileCount} item(s) found`));
1357
+ deadFileCount = result.safeToDelete.length + result.transitivelyDead.length + result.deadExports.length;
1358
+ deadFilePaths.push(...result.safeToDelete, ...result.transitivelyDead);
1359
+ spinner.succeed(
1360
+ import_chalk7.default.green(
1361
+ `Dead code analysis complete \u2014 ${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`
1362
+ )
1363
+ );
1280
1364
  if (result.report) {
1281
1365
  deadReportFile = "dead-code.txt";
1282
- writeReport(reportsDir, deadReportFile, result.report);
1366
+ const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
1367
+
1368
+ `;
1369
+ writeReport(reportsDir, deadReportFile, banner + result.report);
1283
1370
  }
1284
1371
  }
1285
1372
  if (modules.includes("dupes")) {
@@ -1295,8 +1382,28 @@ async function main(opts) {
1295
1382
  spinner.succeed(import_chalk7.default.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
1296
1383
  if (circularCount > 0) {
1297
1384
  circularReportFile = "circular.txt";
1298
- const cycleText = cycles.map((c, i) => `Cycle ${i + 1}: ${c.join(" \u2192 ")}`).join("\n");
1299
- writeReport(reportsDir, circularReportFile, cycleText);
1385
+ const rel = (p) => import_node_path9.default.relative(rootDir, p).replaceAll("\\", "/");
1386
+ const cycleLines = [
1387
+ "========================================",
1388
+ " CIRCULAR DEPENDENCIES",
1389
+ ` Cycles found: ${cycles.length}`,
1390
+ "========================================",
1391
+ ""
1392
+ ];
1393
+ for (let i = 0; i < cycles.length; i++) {
1394
+ const cycle = cycles[i];
1395
+ cycleLines.push(`Cycle ${i + 1} (${cycle.length} files):`);
1396
+ for (let j = 0; j < cycle.length; j++) {
1397
+ const arrow = j < cycle.length - 1 ? " \u2192 " : " \u2192 ";
1398
+ cycleLines.push(` ${rel(cycle[j])}`);
1399
+ }
1400
+ cycleLines.push(` \u21BB ${rel(cycle[0])} (back to start)`);
1401
+ cycleLines.push("");
1402
+ }
1403
+ const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
1404
+
1405
+ `;
1406
+ writeReport(reportsDir, circularReportFile, banner + cycleLines.join("\n"));
1300
1407
  }
1301
1408
  }
1302
1409
  if (modules.includes("deps")) {
@@ -1329,10 +1436,10 @@ async function main(opts) {
1329
1436
  });
1330
1437
  const fmt = (n) => n > 0 ? import_chalk7.default.yellow(String(n)) : import_chalk7.default.green("0");
1331
1438
  table.push(
1332
- ["Dead Files / Exports", fmt(deadFileCount), deadReportFile || "\u2014"],
1439
+ ["Dead Code (files + exports)", fmt(deadFileCount), deadReportFile || "\u2014"],
1440
+ ["Circular Dependencies", fmt(circularCount), circularReportFile || "\u2014"],
1333
1441
  ["Duplicate Clusters", fmt(dupeCount), dupesReportFile || "\u2014"],
1334
1442
  ["Unused Packages", fmt(unusedPkgCount), depsReportFile || "\u2014"],
1335
- ["Circular Deps", fmt(circularCount), circularReportFile || "\u2014"],
1336
1443
  ["Unused Assets", fmt(unusedAssetCount), assetsReportFile || "\u2014"]
1337
1444
  );
1338
1445
  console.log(table.toString());