prunify 0.1.1 → 0.1.4

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
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
- import chalk6 from "chalk";
6
- import Table5 from "cli-table3";
7
- import fs8 from "fs";
8
- import path8 from "path";
5
+ import chalk7 from "chalk";
6
+ import Table6 from "cli-table3";
7
+ import fs9 from "fs";
8
+ import path9 from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import readline from "readline";
11
11
 
@@ -57,7 +57,14 @@ var DEFAULT_IGNORE = [
57
57
  "coverage",
58
58
  "coverage/**",
59
59
  "**/*.test.ts",
60
+ "**/*.test.tsx",
61
+ "**/*.test.js",
62
+ "**/*.test.jsx",
60
63
  "**/*.spec.ts",
64
+ "**/*.spec.tsx",
65
+ "**/*.spec.js",
66
+ "**/*.spec.jsx",
67
+ "**/*.stories.ts",
61
68
  "**/*.stories.tsx",
62
69
  "**/*.d.ts"
63
70
  ];
@@ -202,45 +209,22 @@ function buildGraph(files, getImports) {
202
209
  function findEntryPoints(rootDir, packageJson) {
203
210
  const entries = [
204
211
  ...resolveNextJsEntries(rootDir),
205
- ...resolvePkgFieldEntries(rootDir, packageJson)
212
+ ...resolveFileBrowserEntries(rootDir),
213
+ ...resolvePkgFieldEntries(rootDir, packageJson),
214
+ ...resolveFallbackEntries(rootDir)
206
215
  ];
207
- if (entries.length === 0) {
208
- const fallback = resolveFallbackEntry(rootDir);
209
- if (fallback) entries.push(fallback);
210
- }
211
216
  return [...new Set(entries)];
212
217
  }
213
- function runDFS(graph, entryPoints) {
214
- const visited = /* @__PURE__ */ new Set();
215
- const stack = [...entryPoints];
216
- let node;
217
- while ((node = stack.pop()) !== void 0) {
218
- if (visited.has(node)) continue;
219
- visited.add(node);
220
- for (const neighbor of graph.get(node) ?? []) {
221
- if (!visited.has(neighbor)) stack.push(neighbor);
222
- }
223
- }
224
- return visited;
225
- }
226
- function findDeadChains(graph, deadFiles) {
227
- const reverseGraph = buildReverseGraph(graph);
228
- const result = /* @__PURE__ */ new Map();
229
- for (const deadRoot of deadFiles) {
230
- result.set(deadRoot, dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph));
231
- }
232
- return result;
233
- }
234
218
  function detectCycles(graph) {
235
219
  const cycles = [];
236
220
  const seenKeys = /* @__PURE__ */ new Set();
237
221
  const visited = /* @__PURE__ */ new Set();
238
222
  const inStack = /* @__PURE__ */ new Set();
239
- const path9 = [];
223
+ const path10 = [];
240
224
  const acc = { seenKeys, cycles };
241
225
  for (const start of graph.keys()) {
242
226
  if (!visited.has(start)) {
243
- dfsForCycles(start, graph, visited, inStack, path9, acc);
227
+ dfsForCycles(start, graph, visited, inStack, path10, acc);
244
228
  }
245
229
  }
246
230
  return cycles;
@@ -249,12 +233,21 @@ function resolveNextJsEntries(rootDir) {
249
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"));
250
234
  if (!isNext) return [];
251
235
  const entries = [];
252
- for (const dir of ["pages", "app"]) {
236
+ for (const dir of ["pages", "app", "src/pages", "src/app"]) {
253
237
  const dirPath = path3.join(rootDir, dir);
254
238
  if (fs3.existsSync(dirPath)) entries.push(...collectSourceFiles(dirPath));
255
239
  }
256
240
  return entries;
257
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
+ }
258
251
  function resolvePkgFieldEntries(rootDir, packageJson) {
259
252
  const entries = [];
260
253
  for (const field of ["main", "module"]) {
@@ -265,18 +258,31 @@ function resolvePkgFieldEntries(rootDir, packageJson) {
265
258
  }
266
259
  return entries;
267
260
  }
268
- function resolveFallbackEntry(rootDir) {
269
- const fallbacks = ["src/main.ts", "src/main.tsx", "src/index.ts", "src/index.tsx"];
270
- for (const rel of fallbacks) {
271
- const abs = path3.join(rootDir, rel);
272
- if (fs3.existsSync(abs)) return abs;
273
- }
274
- return void 0;
261
+ function resolveFallbackEntries(rootDir) {
262
+ const candidates = [
263
+ "src/main.ts",
264
+ "src/main.tsx",
265
+ "src/main.js",
266
+ "src/main.jsx",
267
+ "src/index.ts",
268
+ "src/index.tsx",
269
+ "src/index.js",
270
+ "src/index.jsx",
271
+ "src/App.ts",
272
+ "src/App.tsx",
273
+ "src/App.js",
274
+ "src/App.jsx",
275
+ "index.ts",
276
+ "index.tsx",
277
+ "index.js",
278
+ "index.jsx"
279
+ ];
280
+ return candidates.map((rel) => path3.join(rootDir, rel)).filter((abs) => fs3.existsSync(abs));
275
281
  }
276
282
  function mkFrame(node, graph) {
277
283
  return { node, neighbors: (graph.get(node) ?? /* @__PURE__ */ new Set()).values(), entered: false };
278
284
  }
279
- function dfsForCycles(start, graph, visited, inStack, path9, acc) {
285
+ function dfsForCycles(start, graph, visited, inStack, path10, acc) {
280
286
  const stack = [mkFrame(start, graph)];
281
287
  while (stack.length > 0) {
282
288
  const frame = stack.at(-1);
@@ -288,57 +294,36 @@ function dfsForCycles(start, graph, visited, inStack, path9, acc) {
288
294
  }
289
295
  frame.entered = true;
290
296
  inStack.add(frame.node);
291
- path9.push(frame.node);
297
+ path10.push(frame.node);
292
298
  }
293
299
  const { done, value: neighbor } = frame.neighbors.next();
294
300
  if (done) {
295
301
  stack.pop();
296
- path9.pop();
302
+ path10.pop();
297
303
  inStack.delete(frame.node);
298
304
  visited.add(frame.node);
299
305
  } else {
300
- handleCycleNeighbor(neighbor, stack, path9, inStack, visited, acc, graph);
306
+ handleCycleNeighbor(neighbor, stack, path10, inStack, visited, acc, graph);
301
307
  }
302
308
  }
303
309
  }
304
- function handleCycleNeighbor(neighbor, stack, path9, inStack, visited, acc, graph) {
310
+ function handleCycleNeighbor(neighbor, stack, path10, inStack, visited, acc, graph) {
305
311
  if (inStack.has(neighbor)) {
306
- recordCycle(neighbor, path9, acc);
312
+ recordCycle(neighbor, path10, acc);
307
313
  } else if (!visited.has(neighbor)) {
308
314
  stack.push(mkFrame(neighbor, graph));
309
315
  }
310
316
  }
311
- function recordCycle(cycleStart, path9, acc) {
312
- const idx = path9.indexOf(cycleStart);
317
+ function recordCycle(cycleStart, path10, acc) {
318
+ const idx = path10.indexOf(cycleStart);
313
319
  if (idx === -1) return;
314
- const cycle = normalizeCycle(path9.slice(idx));
320
+ const cycle = normalizeCycle(path10.slice(idx));
315
321
  const key = cycle.join("\0");
316
322
  if (!acc.seenKeys.has(key)) {
317
323
  acc.seenKeys.add(key);
318
324
  acc.cycles.push(cycle);
319
325
  }
320
326
  }
321
- function dfsDeadChain(deadRoot, graph, deadFiles, reverseGraph) {
322
- const chain = [];
323
- const visited = /* @__PURE__ */ new Set();
324
- const stack = [...graph.get(deadRoot) ?? []];
325
- let node;
326
- while ((node = stack.pop()) !== void 0) {
327
- if (visited.has(node) || node === deadRoot) continue;
328
- visited.add(node);
329
- if (deadFiles.has(node) || isOnlyImportedByDead(node, deadFiles, reverseGraph)) {
330
- chain.push(node);
331
- for (const next of graph.get(node) ?? []) {
332
- if (!visited.has(next)) stack.push(next);
333
- }
334
- }
335
- }
336
- return chain;
337
- }
338
- function isOnlyImportedByDead(file, deadFiles, reverseGraph) {
339
- const importers = reverseGraph.get(file) ?? /* @__PURE__ */ new Set();
340
- return importers.size === 0 || [...importers].every((imp) => deadFiles.has(imp));
341
- }
342
327
  function buildReverseGraph(graph) {
343
328
  const rev = /* @__PURE__ */ new Map();
344
329
  for (const [file] of graph) {
@@ -465,16 +450,101 @@ function ensureDir(dir) {
465
450
  }
466
451
 
467
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
+ }
468
480
  function runDeadCodeModule(project, graph, entryPoints, rootDir) {
469
481
  const allFiles = [...graph.keys()];
470
- const effectiveEntries = entryPoints.length > 0 ? entryPoints : allFiles.slice(0, 1);
471
- const liveFiles = runDFS(graph, effectiveEntries);
472
- const deadFiles = allFiles.filter((f) => !liveFiles.has(f));
473
- const deadSet = new Set(deadFiles);
474
- 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)));
475
514
  const deadExports = findDeadExports(project, liveFiles);
476
- const report = buildDeadCodeReport(deadFiles, chains, deadExports, rootDir);
477
- 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;
478
548
  }
479
549
  function findDeadExports(project, liveFiles) {
480
550
  const importedNames = buildImportedNameMap(project, liveFiles);
@@ -507,51 +577,61 @@ function getFileSize(filePath) {
507
577
  return 0;
508
578
  }
509
579
  }
510
- function buildDeadCodeReport(deadFiles, chains, deadExports, rootDir) {
580
+ function buildDeadCodeReport(safeToDelete, transitivelyDead, chains, deadExports, rootDir) {
511
581
  const rel = (p) => path5.relative(rootDir, p).replaceAll("\\", "/");
512
- const totalBytes = deadFiles.reduce((sum, f) => {
513
- const chain = chains.get(f) ?? [];
514
- return sum + getFileSize(f) + chain.reduce((s, c) => s + getFileSize(c), 0);
515
- }, 0);
582
+ const allDeadFiles = [...safeToDelete, ...transitivelyDead];
583
+ const totalBytes = allDeadFiles.reduce((sum, f) => sum + getFileSize(f), 0);
516
584
  const totalKb = (totalBytes / 1024).toFixed(1);
517
585
  const lines = [
518
586
  "========================================",
519
587
  " DEAD CODE REPORT",
520
- ` Dead files : ${deadFiles.length}`,
521
- ` Dead exports: ${deadExports.length}`,
522
- ` Recoverable : ~${totalKb} KB`,
588
+ ` Safe to delete : ${safeToDelete.length}`,
589
+ ` Transitively dead : ${transitivelyDead.length}`,
590
+ ` Dead exports : ${deadExports.length}`,
591
+ ` Recoverable : ~${totalKb} KB`,
523
592
  "========================================",
524
593
  ""
525
594
  ];
526
- if (deadFiles.length > 0) {
527
- lines.push("\u2500\u2500 DEAD FILES \u2500\u2500", "");
528
- 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) {
529
602
  const chain = chains.get(filePath) ?? [];
530
- const allFiles = [filePath, ...chain];
531
- const sizeBytes = allFiles.reduce((s, f) => s + getFileSize(f), 0);
532
- const sizeKb = (sizeBytes / 1024).toFixed(1);
533
- const chainStr = chain.length > 0 ? [rel(filePath), ...chain.map(rel)].join(" \u2192 ") : rel(filePath);
534
- const plural = allFiles.length === 1 ? "" : "s";
535
- lines.push(
536
- `DEAD FILE \u2014 ${rel(filePath)}`,
537
- `Reason: Not imported anywhere in the codebase`,
538
- `Chain: ${chainStr}`,
539
- `Size: ~${sizeKb} KB removable across ${allFiles.length} file${plural}`,
540
- `Action: Safe to delete all ${allFiles.length} file${plural}`,
541
- ""
542
- );
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)`);
543
620
  }
621
+ lines.push("");
544
622
  }
545
623
  if (deadExports.length > 0) {
546
- 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
+ );
547
629
  for (const entry of deadExports) {
548
630
  lines.push(
549
- `DEAD EXPORT \u2014 ${rel(entry.filePath)} \u2192 ${entry.exportName}() [line ${entry.line}]`,
550
- `Reason: Exported but never imported`,
551
- `Action: Remove the export (file itself is still live)`,
552
- ""
631
+ ` ${rel(entry.filePath)} \u2192 ${entry.exportName} [line ${entry.line}]`
553
632
  );
554
633
  }
634
+ lines.push("");
555
635
  }
556
636
  return lines.join("\n");
557
637
  }
@@ -568,7 +648,8 @@ async function runDeadCode(dir, opts) {
568
648
  const entries = findEntryPoints(dir, packageJson);
569
649
  const result = runDeadCodeModule(project, graph, entries, dir);
570
650
  const dead = [
571
- ...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)" })),
572
653
  ...result.deadExports.map((e) => ({ file: e.filePath, exportName: e.exportName }))
573
654
  ];
574
655
  spinner.succeed(chalk.green(`Dead code scan complete \u2014 ${dead.length} item(s) found`));
@@ -644,7 +725,11 @@ function writeDeadOutput(result, opts) {
644
725
  if (!opts.output) return;
645
726
  if (opts.json) {
646
727
  writeJson(
647
- { deadFiles: result.deadFiles, deadExports: result.deadExports },
728
+ {
729
+ safeToDelete: result.safeToDelete,
730
+ transitivelyDead: result.transitivelyDead,
731
+ deadExports: result.deadExports
732
+ },
648
733
  opts.output
649
734
  );
650
735
  console.log(chalk.cyan(` JSON written to ${opts.output}`));
@@ -653,16 +738,21 @@ function writeDeadOutput(result, opts) {
653
738
  writeMarkdown(
654
739
  {
655
740
  title: "Dead Code Report",
656
- 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)`,
657
742
  sections: [
658
743
  {
659
- title: "Dead Files",
744
+ title: "Safe to Delete",
660
745
  headers: ["File", "Chain"],
661
- rows: result.deadFiles.map((f) => [
746
+ rows: result.safeToDelete.map((f) => [
662
747
  f,
663
748
  (result.chains.get(f) ?? []).join(" \u2192 ") || "\u2014"
664
749
  ])
665
750
  },
751
+ {
752
+ title: "Transitively Dead",
753
+ headers: ["File"],
754
+ rows: result.transitivelyDead.map((f) => [f])
755
+ },
666
756
  {
667
757
  title: "Dead Exports",
668
758
  headers: ["File", "Export", "Line"],
@@ -691,17 +781,30 @@ import {
691
781
  } from "ts-morph";
692
782
 
693
783
  // src/modules/dupe-finder.ts
784
+ var MIN_BLOCK_CHARS = 120;
694
785
  async function runDupeFinder(dir, opts) {
695
- const minLines = parseInt(opts.minLines ?? "5", 10);
786
+ const minLines = parseInt(opts.minLines ?? "15", 10);
696
787
  const spinner = ora3(chalk2.cyan(`Scanning for duplicate blocks (\u2265${minLines} lines)\u2026`)).start();
697
788
  try {
698
- 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
+ ]);
699
800
  const blockMap = /* @__PURE__ */ new Map();
700
801
  for (const filePath of files) {
701
802
  const content = fs6.readFileSync(filePath, "utf-8");
702
803
  const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
703
804
  for (let i = 0; i <= lines.length - minLines; i++) {
704
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;
705
808
  if (!blockMap.has(block)) blockMap.set(block, []);
706
809
  blockMap.get(block).push({ file: filePath, startLine: i + 1 });
707
810
  }
@@ -716,6 +819,8 @@ async function runDupeFinder(dir, opts) {
716
819
  });
717
820
  }
718
821
  }
822
+ dupes.sort((a, b) => b.occurrences.length - a.occurrences.length);
823
+ if (dupes.length > 200) dupes.splice(200);
719
824
  spinner.succeed(chalk2.green(`Duplicate scan complete \u2014 ${dupes.length} duplicate block(s) found`));
720
825
  if (dupes.length === 0) {
721
826
  console.log(chalk2.green(" No duplicate blocks detected."));
@@ -967,26 +1072,218 @@ async function runHealthReport(dir, opts) {
967
1072
  );
968
1073
  }
969
1074
 
1075
+ // src/modules/assets.ts
1076
+ import fs8 from "fs";
1077
+ import path8 from "path";
1078
+ import ora6 from "ora";
1079
+ import chalk6 from "chalk";
1080
+ import Table5 from "cli-table3";
1081
+ var ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
1082
+ ".png",
1083
+ ".jpg",
1084
+ ".jpeg",
1085
+ ".gif",
1086
+ ".svg",
1087
+ ".webp",
1088
+ ".avif",
1089
+ ".ico",
1090
+ ".bmp",
1091
+ ".woff",
1092
+ ".woff2",
1093
+ ".ttf",
1094
+ ".eot",
1095
+ ".otf",
1096
+ ".mp4",
1097
+ ".webm",
1098
+ ".ogg",
1099
+ ".mp3",
1100
+ ".wav",
1101
+ ".pdf"
1102
+ ]);
1103
+ var SOURCE_PATTERNS2 = [
1104
+ "**/*.ts",
1105
+ "**/*.tsx",
1106
+ "**/*.js",
1107
+ "**/*.jsx",
1108
+ "**/*.css",
1109
+ "**/*.scss",
1110
+ "**/*.sass",
1111
+ "**/*.less",
1112
+ "**/*.html",
1113
+ "**/*.json"
1114
+ ];
1115
+ var SOURCE_IGNORE = [
1116
+ "node_modules",
1117
+ "node_modules/**",
1118
+ "dist",
1119
+ "dist/**",
1120
+ ".next",
1121
+ ".next/**",
1122
+ "coverage",
1123
+ "coverage/**",
1124
+ "public",
1125
+ "public/**"
1126
+ ];
1127
+ function runAssetCheckModule(rootDir) {
1128
+ const publicDir = path8.join(rootDir, "public");
1129
+ if (!fs8.existsSync(publicDir)) {
1130
+ return { unusedAssets: [], totalAssets: 0, report: buildAssetReport([], 0, rootDir) };
1131
+ }
1132
+ const assets = collectAssets(publicDir);
1133
+ if (assets.length === 0) {
1134
+ return { unusedAssets: [], totalAssets: 0, report: buildAssetReport([], 0, rootDir) };
1135
+ }
1136
+ const sourceFiles = glob(rootDir, SOURCE_PATTERNS2, SOURCE_IGNORE);
1137
+ const sourceContent = sourceFiles.reduce((acc, f) => {
1138
+ try {
1139
+ return acc + fs8.readFileSync(f, "utf-8") + "\n";
1140
+ } catch {
1141
+ return acc;
1142
+ }
1143
+ }, "");
1144
+ const unused = [];
1145
+ for (const assetAbs of assets) {
1146
+ const fileName = path8.basename(assetAbs);
1147
+ const relFromPublic = "/" + path8.relative(publicDir, assetAbs).replaceAll("\\", "/");
1148
+ const referenced = sourceContent.includes(fileName) || sourceContent.includes(relFromPublic);
1149
+ if (!referenced) {
1150
+ unused.push({
1151
+ filePath: assetAbs,
1152
+ relativePath: path8.relative(rootDir, assetAbs).replaceAll("\\", "/"),
1153
+ sizeBytes: getFileSize2(assetAbs)
1154
+ });
1155
+ }
1156
+ }
1157
+ const report = buildAssetReport(unused, assets.length, rootDir);
1158
+ return { unusedAssets: unused, totalAssets: assets.length, report };
1159
+ }
1160
+ async function runAssetCheck(rootDir, opts) {
1161
+ const publicDir = path8.join(rootDir, "public");
1162
+ if (!fs8.existsSync(publicDir)) {
1163
+ console.log(chalk6.dim(" No public/ folder found \u2014 skipping asset check"));
1164
+ return [];
1165
+ }
1166
+ const spinner = ora6(chalk6.cyan("Scanning public/ for unused assets\u2026")).start();
1167
+ try {
1168
+ const result = runAssetCheckModule(rootDir);
1169
+ spinner.succeed(
1170
+ chalk6.green(
1171
+ `Asset scan complete \u2014 ${result.unusedAssets.length} unused / ${result.totalAssets} total`
1172
+ )
1173
+ );
1174
+ if (result.unusedAssets.length === 0) {
1175
+ console.log(chalk6.green(" All public assets are referenced in source."));
1176
+ return [];
1177
+ }
1178
+ const table = new Table5({ head: ["Asset", "Size"] });
1179
+ for (const asset of result.unusedAssets) {
1180
+ const kb = (asset.sizeBytes / 1024).toFixed(1);
1181
+ table.push([chalk6.gray(asset.relativePath), `${kb} KB`]);
1182
+ }
1183
+ console.log(table.toString());
1184
+ if (opts.output) {
1185
+ writeMarkdown(
1186
+ {
1187
+ title: "Unused Assets Report",
1188
+ summary: `${result.unusedAssets.length} unused asset(s) found in public/`,
1189
+ sections: [
1190
+ {
1191
+ title: "Unused Assets",
1192
+ headers: ["Asset", "Size (KB)"],
1193
+ rows: result.unusedAssets.map((a) => [
1194
+ a.relativePath,
1195
+ (a.sizeBytes / 1024).toFixed(1)
1196
+ ])
1197
+ }
1198
+ ],
1199
+ generatedAt: /* @__PURE__ */ new Date()
1200
+ },
1201
+ opts.output
1202
+ );
1203
+ console.log(chalk6.cyan(` Report written to ${opts.output}`));
1204
+ }
1205
+ return result.unusedAssets;
1206
+ } catch (err) {
1207
+ spinner.fail(chalk6.red("Asset scan failed"));
1208
+ throw err;
1209
+ }
1210
+ }
1211
+ function collectAssets(dir) {
1212
+ const results = [];
1213
+ function walk(current) {
1214
+ let entries;
1215
+ try {
1216
+ entries = fs8.readdirSync(current, { withFileTypes: true });
1217
+ } catch {
1218
+ return;
1219
+ }
1220
+ for (const entry of entries) {
1221
+ const full = path8.join(current, entry.name);
1222
+ if (entry.isDirectory()) {
1223
+ walk(full);
1224
+ } else if (entry.isFile() && ASSET_EXTENSIONS.has(path8.extname(entry.name).toLowerCase())) {
1225
+ results.push(full);
1226
+ }
1227
+ }
1228
+ }
1229
+ walk(dir);
1230
+ return results;
1231
+ }
1232
+ function getFileSize2(filePath) {
1233
+ try {
1234
+ return fs8.statSync(filePath).size;
1235
+ } catch {
1236
+ return 0;
1237
+ }
1238
+ }
1239
+ function buildAssetReport(unused, totalAssets, rootDir) {
1240
+ const totalBytes = unused.reduce((s, a) => s + a.sizeBytes, 0);
1241
+ const totalKb = (totalBytes / 1024).toFixed(1);
1242
+ const lines = [
1243
+ "========================================",
1244
+ " UNUSED ASSETS REPORT",
1245
+ ` Total assets : ${totalAssets}`,
1246
+ ` Unused assets : ${unused.length}`,
1247
+ ` Recoverable : ~${totalKb} KB`,
1248
+ "========================================",
1249
+ ""
1250
+ ];
1251
+ if (unused.length === 0) {
1252
+ lines.push(" All public assets are referenced in source.", "");
1253
+ return lines.join("\n");
1254
+ }
1255
+ lines.push("\u2500\u2500 UNUSED ASSETS \u2500\u2500", "");
1256
+ for (const asset of unused) {
1257
+ lines.push(
1258
+ `UNUSED \u2014 ${asset.relativePath}`,
1259
+ `Size: ~${(asset.sizeBytes / 1024).toFixed(1)} KB`,
1260
+ `Action: Safe to delete if not served directly via URL`,
1261
+ ""
1262
+ );
1263
+ }
1264
+ return lines.join("\n");
1265
+ }
1266
+
970
1267
  // src/cli.ts
971
1268
  function readPkgVersion() {
972
1269
  try {
973
1270
  if (typeof import.meta !== "undefined" && import.meta.url) {
974
- const dir = path8.dirname(fileURLToPath(import.meta.url));
975
- const pkgPath = path8.resolve(dir, "..", "package.json");
976
- return JSON.parse(fs8.readFileSync(pkgPath, "utf-8")).version;
1271
+ const dir = path9.dirname(fileURLToPath(import.meta.url));
1272
+ const pkgPath = path9.resolve(dir, "..", "package.json");
1273
+ return JSON.parse(fs9.readFileSync(pkgPath, "utf-8")).version;
977
1274
  }
978
1275
  } catch {
979
1276
  }
980
1277
  try {
981
1278
  const dir = globalThis.__dirname ?? __dirname;
982
- const pkgPath = path8.resolve(dir, "..", "package.json");
983
- return JSON.parse(fs8.readFileSync(pkgPath, "utf-8")).version;
1279
+ const pkgPath = path9.resolve(dir, "..", "package.json");
1280
+ return JSON.parse(fs9.readFileSync(pkgPath, "utf-8")).version;
984
1281
  } catch {
985
1282
  return "0.0.0";
986
1283
  }
987
1284
  }
988
1285
  var PKG_VERSION = readPkgVersion();
989
- var ALL_MODULES = ["dead-code", "dupes", "circular", "deps"];
1286
+ var ALL_MODULES = ["dead-code", "dupes", "circular", "deps", "assets"];
990
1287
  var program = new Command();
991
1288
  program.name("prunify").description("npm run clean. ship with confidence.").version(PKG_VERSION, "-v, --version").option("--dir <path>", "Root directory to analyze", process.cwd()).option("--entry <path>", "Override entry point").option("--only <modules>", "Comma-separated: dead-code,dupes,circular,deps,health").option(
992
1289
  "--ignore <pattern>",
@@ -996,29 +1293,29 @@ program.name("prunify").description("npm run clean. ship with confidence.").vers
996
1293
  ).option("--out <path>", "Output directory for reports").option("--html", "Also generate code_health.html").option("--delete", "Prompt to delete dead files after analysis").option("--ci", "CI mode: exit 1 if issues found, no interactive prompts").action(main);
997
1294
  program.parse();
998
1295
  async function main(opts) {
999
- const rootDir = path8.resolve(opts.dir);
1000
- if (!fs8.existsSync(path8.join(rootDir, "package.json"))) {
1001
- console.error(chalk6.red(`\u2717 No package.json found in ${rootDir}`));
1002
- console.error(chalk6.dim(" Use --dir <path> to point to your project root."));
1296
+ const rootDir = path9.resolve(opts.dir);
1297
+ if (!fs9.existsSync(path9.join(rootDir, "package.json"))) {
1298
+ console.error(chalk7.red(`\u2717 No package.json found in ${rootDir}`));
1299
+ console.error(chalk7.dim(" Use --dir <path> to point to your project root."));
1003
1300
  process.exit(1);
1004
1301
  }
1005
1302
  const modules = resolveModules(opts.only);
1006
1303
  console.log();
1007
- console.log(chalk6.bold.cyan("\u{1F9F9} prunify \u2014 npm run clean. ship with confidence."));
1304
+ console.log(chalk7.bold.cyan("\u{1F9F9} prunify \u2014 npm run clean. ship with confidence."));
1008
1305
  console.log();
1009
- const parseSpinner = createSpinner(chalk6.cyan("Parsing codebase\u2026"));
1306
+ const parseSpinner = createSpinner(chalk7.cyan("Parsing codebase\u2026"));
1010
1307
  const files = discoverFiles(rootDir, opts.ignore);
1011
- parseSpinner.succeed(chalk6.green(`Parsed codebase \u2014 ${files.length} file(s) found`));
1012
- const graphSpinner = createSpinner(chalk6.cyan("Building import graph\u2026"));
1308
+ parseSpinner.succeed(chalk7.green(`Parsed codebase \u2014 ${files.length} file(s) found`));
1309
+ const graphSpinner = createSpinner(chalk7.cyan("Building import graph\u2026"));
1013
1310
  const project = buildProject(files);
1014
1311
  const graph = buildGraph(files, (f) => {
1015
1312
  const sf = project.getSourceFile(f);
1016
1313
  return sf ? getImportsForFile(sf) : [];
1017
1314
  });
1018
1315
  const edgeCount = [...graph.values()].reduce((n, s) => n + s.size, 0);
1019
- graphSpinner.succeed(chalk6.green(`Import graph built \u2014 ${edgeCount} edge(s)`));
1316
+ graphSpinner.succeed(chalk7.green(`Import graph built \u2014 ${edgeCount} edge(s)`));
1020
1317
  const packageJson = loadPackageJson2(rootDir);
1021
- const entryPoints = opts.entry ? [path8.resolve(opts.entry)] : findEntryPoints(rootDir, packageJson);
1318
+ const entryPoints = opts.entry ? [path9.resolve(opts.entry)] : findEntryPoints(rootDir, packageJson);
1022
1319
  const reportsDir = ensureReportsDir(rootDir, opts.out);
1023
1320
  appendToGitignore(rootDir);
1024
1321
  console.log();
@@ -1026,90 +1323,120 @@ async function main(opts) {
1026
1323
  let dupeCount = 0;
1027
1324
  let unusedPkgCount = 0;
1028
1325
  let circularCount = 0;
1326
+ let unusedAssetCount = 0;
1029
1327
  let deadReportFile = "";
1030
1328
  let dupesReportFile = "";
1031
1329
  let depsReportFile = "";
1032
1330
  let circularReportFile = "";
1331
+ let assetsReportFile = "";
1033
1332
  const deadFilePaths = [];
1034
1333
  if (modules.includes("dead-code")) {
1035
- const spinner = createSpinner(chalk6.cyan("Analysing dead code\u2026"));
1334
+ const spinner = createSpinner(chalk7.cyan("Analysing dead code\u2026"));
1036
1335
  const result = runDeadCodeModule(project, graph, entryPoints, rootDir);
1037
- deadFileCount = result.deadFiles.length + result.deadExports.length;
1038
- deadFilePaths.push(...result.deadFiles);
1039
- spinner.succeed(chalk6.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
+ );
1040
1343
  if (result.report) {
1041
1344
  deadReportFile = "dead-code.txt";
1042
1345
  writeReport(reportsDir, deadReportFile, result.report);
1043
1346
  }
1044
1347
  }
1045
1348
  if (modules.includes("dupes")) {
1046
- const outputPath = path8.join(reportsDir, "dupes.md");
1349
+ const outputPath = path9.join(reportsDir, "dupes.md");
1047
1350
  const dupes = await runDupeFinder(rootDir, { output: outputPath });
1048
1351
  dupeCount = dupes.length;
1049
1352
  if (dupeCount > 0) dupesReportFile = "dupes.md";
1050
1353
  }
1051
1354
  if (modules.includes("circular")) {
1052
- const spinner = createSpinner(chalk6.cyan("Analysing circular imports\u2026"));
1355
+ const spinner = createSpinner(chalk7.cyan("Analysing circular imports\u2026"));
1053
1356
  const cycles = detectCycles(graph);
1054
1357
  circularCount = cycles.length;
1055
- spinner.succeed(chalk6.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
1358
+ spinner.succeed(chalk7.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
1056
1359
  if (circularCount > 0) {
1057
1360
  circularReportFile = "circular.txt";
1058
- const cycleText = cycles.map((c, i) => `Cycle ${i + 1}: ${c.join(" \u2192 ")}`).join("\n");
1059
- writeReport(reportsDir, circularReportFile, cycleText);
1361
+ const rel = (p) => path9.relative(rootDir, p).replaceAll("\\", "/");
1362
+ const cycleLines = [
1363
+ "========================================",
1364
+ " CIRCULAR DEPENDENCIES",
1365
+ ` Cycles found: ${cycles.length}`,
1366
+ "========================================",
1367
+ ""
1368
+ ];
1369
+ for (let i = 0; i < cycles.length; i++) {
1370
+ const cycle = cycles[i];
1371
+ cycleLines.push(`Cycle ${i + 1} (${cycle.length} files):`);
1372
+ for (let j = 0; j < cycle.length; j++) {
1373
+ const arrow = j < cycle.length - 1 ? " \u2192 " : " \u2192 ";
1374
+ cycleLines.push(` ${rel(cycle[j])}`);
1375
+ }
1376
+ cycleLines.push(` \u21BB ${rel(cycle[0])} (back to start)`);
1377
+ cycleLines.push("");
1378
+ }
1379
+ writeReport(reportsDir, circularReportFile, cycleLines.join("\n"));
1060
1380
  }
1061
1381
  }
1062
1382
  if (modules.includes("deps")) {
1063
- const outputPath = path8.join(reportsDir, "deps.md");
1383
+ const outputPath = path9.join(reportsDir, "deps.md");
1064
1384
  const issues = await runDepCheck({ cwd: rootDir, output: outputPath });
1065
1385
  unusedPkgCount = issues.filter((i) => i.type === "unused").length;
1066
1386
  if (issues.length > 0) depsReportFile = "deps.md";
1067
1387
  }
1388
+ if (modules.includes("assets")) {
1389
+ const outputPath = path9.join(reportsDir, "assets.md");
1390
+ const unusedAssets = await runAssetCheck(rootDir, { output: outputPath });
1391
+ unusedAssetCount = unusedAssets.length;
1392
+ if (unusedAssetCount > 0) assetsReportFile = "assets.md";
1393
+ }
1068
1394
  if (modules.includes("health")) {
1069
- const outputPath = path8.join(reportsDir, "health-report.md");
1395
+ const outputPath = path9.join(reportsDir, "health-report.md");
1070
1396
  await runHealthReport(rootDir, { output: outputPath });
1071
1397
  }
1072
1398
  if (opts.html) {
1073
- const htmlPath = path8.join(reportsDir, "code_health.html");
1399
+ const htmlPath = path9.join(reportsDir, "code_health.html");
1074
1400
  writeHtmlReport(htmlPath, rootDir, deadFilePaths, circularCount, dupeCount, unusedPkgCount);
1075
- console.log(chalk6.cyan(` HTML report written to ${htmlPath}`));
1401
+ console.log(chalk7.cyan(` HTML report written to ${htmlPath}`));
1076
1402
  }
1077
1403
  console.log();
1078
- console.log(chalk6.bold("Summary"));
1404
+ console.log(chalk7.bold("Summary"));
1079
1405
  console.log();
1080
- const table = new Table5({
1081
- head: [chalk6.bold("Check"), chalk6.bold("Found"), chalk6.bold("Output File")],
1406
+ const table = new Table6({
1407
+ head: [chalk7.bold("Check"), chalk7.bold("Found"), chalk7.bold("Output File")],
1082
1408
  style: { head: [], border: [] }
1083
1409
  });
1084
- const fmt = (n) => n > 0 ? chalk6.yellow(String(n)) : chalk6.green("0");
1410
+ const fmt = (n) => n > 0 ? chalk7.yellow(String(n)) : chalk7.green("0");
1085
1411
  table.push(
1086
- ["Dead Files / Exports", fmt(deadFileCount), deadReportFile || "\u2014"],
1412
+ ["Dead Code (files + exports)", fmt(deadFileCount), deadReportFile || "\u2014"],
1413
+ ["Circular Dependencies", fmt(circularCount), circularReportFile || "\u2014"],
1087
1414
  ["Duplicate Clusters", fmt(dupeCount), dupesReportFile || "\u2014"],
1088
1415
  ["Unused Packages", fmt(unusedPkgCount), depsReportFile || "\u2014"],
1089
- ["Circular Deps", fmt(circularCount), circularReportFile || "\u2014"]
1416
+ ["Unused Assets", fmt(unusedAssetCount), assetsReportFile || "\u2014"]
1090
1417
  );
1091
1418
  console.log(table.toString());
1092
1419
  console.log();
1093
1420
  if (opts.delete && deadFilePaths.length > 0) {
1094
- console.log(chalk6.yellow(`Dead files (${deadFilePaths.length}):`));
1421
+ console.log(chalk7.yellow(`Dead files (${deadFilePaths.length}):`));
1095
1422
  for (const f of deadFilePaths) {
1096
- console.log(chalk6.dim(` ${path8.relative(rootDir, f)}`));
1423
+ console.log(chalk7.dim(` ${path9.relative(rootDir, f)}`));
1097
1424
  }
1098
1425
  console.log();
1099
1426
  if (!opts.ci) {
1100
1427
  const confirmed = await confirmPrompt("Delete these files? (y/N) ");
1101
1428
  if (confirmed) {
1102
1429
  for (const f of deadFilePaths) {
1103
- fs8.rmSync(f, { force: true });
1430
+ fs9.rmSync(f, { force: true });
1104
1431
  }
1105
- console.log(chalk6.green(` Deleted ${deadFilePaths.length} file(s).`));
1432
+ console.log(chalk7.green(` Deleted ${deadFilePaths.length} file(s).`));
1106
1433
  } else {
1107
- console.log(chalk6.dim(" Skipped."));
1434
+ console.log(chalk7.dim(" Skipped."));
1108
1435
  }
1109
1436
  }
1110
1437
  }
1111
1438
  if (opts.ci) {
1112
- const hasIssues = deadFileCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0;
1439
+ const hasIssues = deadFileCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0 || unusedAssetCount > 0;
1113
1440
  if (hasIssues) process.exit(1);
1114
1441
  }
1115
1442
  }
@@ -1120,7 +1447,7 @@ function resolveModules(only) {
1120
1447
  }
1121
1448
  function loadPackageJson2(dir) {
1122
1449
  try {
1123
- return JSON.parse(fs8.readFileSync(path8.join(dir, "package.json"), "utf-8"));
1450
+ return JSON.parse(fs9.readFileSync(path9.join(dir, "package.json"), "utf-8"));
1124
1451
  } catch {
1125
1452
  return null;
1126
1453
  }
@@ -1141,7 +1468,7 @@ function writeHtmlReport(outputPath, rootDir, deadFiles, circularCount, dupeCoun
1141
1468
  ["Circular Dependencies", String(circularCount)],
1142
1469
  ["Unused Packages", String(unusedPkgCount)]
1143
1470
  ].map(([label, val]) => ` <tr><td>${label}</td><td>${val}</td></tr>`).join("\n");
1144
- const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${path8.relative(rootDir, f)}</li>`).join("")}</ul>` : "<p>None</p>";
1471
+ const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${path9.relative(rootDir, f)}</li>`).join("")}</ul>` : "<p>None</p>";
1145
1472
  const html = `<!DOCTYPE html>
1146
1473
  <html lang="en">
1147
1474
  <head>
@@ -1168,7 +1495,7 @@ ${rows}
1168
1495
  ${deadList}
1169
1496
  </body>
1170
1497
  </html>`;
1171
- fs8.mkdirSync(path8.dirname(outputPath), { recursive: true });
1172
- fs8.writeFileSync(outputPath, html, "utf-8");
1498
+ fs9.mkdirSync(path9.dirname(outputPath), { recursive: true });
1499
+ fs9.writeFileSync(outputPath, html, "utf-8");
1173
1500
  }
1174
1501
  //# sourceMappingURL=cli.js.map