prunify 0.1.6 → 0.1.8
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/README.md +18 -10
- package/dist/cli.cjs +424 -64
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +424 -64
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -238,6 +238,83 @@ function findEntryPoints(rootDir, packageJson) {
|
|
|
238
238
|
];
|
|
239
239
|
return [...new Set(entries)];
|
|
240
240
|
}
|
|
241
|
+
var APP_SPECIAL_ENTRY_BASENAMES_LOWER = /* @__PURE__ */ new Set([
|
|
242
|
+
"page.tsx",
|
|
243
|
+
"page.ts",
|
|
244
|
+
"page.jsx",
|
|
245
|
+
"page.js",
|
|
246
|
+
"layout.tsx",
|
|
247
|
+
"layout.ts",
|
|
248
|
+
"layout.jsx",
|
|
249
|
+
"layout.js",
|
|
250
|
+
"template.tsx",
|
|
251
|
+
"template.ts",
|
|
252
|
+
"template.jsx",
|
|
253
|
+
"template.js",
|
|
254
|
+
"loading.tsx",
|
|
255
|
+
"loading.ts",
|
|
256
|
+
"loading.jsx",
|
|
257
|
+
"loading.js",
|
|
258
|
+
"error.tsx",
|
|
259
|
+
"error.ts",
|
|
260
|
+
"error.jsx",
|
|
261
|
+
"error.js",
|
|
262
|
+
"global-error.tsx",
|
|
263
|
+
"global-error.ts",
|
|
264
|
+
"global-error.jsx",
|
|
265
|
+
"global-error.js",
|
|
266
|
+
"not-found.tsx",
|
|
267
|
+
"not-found.ts",
|
|
268
|
+
"not-found.jsx",
|
|
269
|
+
"not-found.js",
|
|
270
|
+
"forbidden.tsx",
|
|
271
|
+
"forbidden.ts",
|
|
272
|
+
"forbidden.jsx",
|
|
273
|
+
"forbidden.js",
|
|
274
|
+
"unauthorized.tsx",
|
|
275
|
+
"unauthorized.ts",
|
|
276
|
+
"unauthorized.jsx",
|
|
277
|
+
"unauthorized.js",
|
|
278
|
+
"default.tsx",
|
|
279
|
+
"default.ts",
|
|
280
|
+
"default.jsx",
|
|
281
|
+
"default.js",
|
|
282
|
+
"route.ts",
|
|
283
|
+
"route.js",
|
|
284
|
+
"opengraph-image.tsx",
|
|
285
|
+
"opengraph-image.ts",
|
|
286
|
+
"opengraph-image.js",
|
|
287
|
+
"twitter-image.tsx",
|
|
288
|
+
"twitter-image.ts",
|
|
289
|
+
"twitter-image.js",
|
|
290
|
+
"icon.tsx",
|
|
291
|
+
"icon.ts",
|
|
292
|
+
"icon.jsx",
|
|
293
|
+
"icon.js",
|
|
294
|
+
"apple-icon.tsx",
|
|
295
|
+
"apple-icon.ts",
|
|
296
|
+
"apple-icon.js",
|
|
297
|
+
"sitemap.ts",
|
|
298
|
+
"sitemap.js",
|
|
299
|
+
"robots.ts",
|
|
300
|
+
"robots.js",
|
|
301
|
+
"manifest.ts",
|
|
302
|
+
"manifest.js"
|
|
303
|
+
]);
|
|
304
|
+
function isConventionRoutedSourceFile(filePath, rootDir) {
|
|
305
|
+
const rel = import_node_path3.default.relative(rootDir, import_node_path3.default.normalize(filePath)).replaceAll("\\", "/");
|
|
306
|
+
if (rel.startsWith("..")) return false;
|
|
307
|
+
if (rel === "pages" || rel.startsWith("pages/")) return true;
|
|
308
|
+
if (rel.startsWith("src/pages/")) return true;
|
|
309
|
+
if (rel.startsWith("src/routes/") || rel.startsWith("src/views/") || rel.startsWith("src/screens/")) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (rel.startsWith("app/") || rel.startsWith("src/app/")) {
|
|
313
|
+
const base = import_node_path3.default.basename(rel).toLowerCase();
|
|
314
|
+
if (APP_SPECIAL_ENTRY_BASENAMES_LOWER.has(base)) return true;
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
241
318
|
function detectCycles(graph) {
|
|
242
319
|
const cycles = [];
|
|
243
320
|
const seenKeys = /* @__PURE__ */ new Set();
|
|
@@ -435,6 +512,12 @@ ${entry}
|
|
|
435
512
|
`, "utf-8");
|
|
436
513
|
}
|
|
437
514
|
}
|
|
515
|
+
function formatBytes(bytes) {
|
|
516
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
517
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
518
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
519
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
520
|
+
}
|
|
438
521
|
function writeMarkdown(report, outputPath) {
|
|
439
522
|
const lines = [
|
|
440
523
|
`# ${report.title}`,
|
|
@@ -534,7 +617,7 @@ function runDeadCodeModule(project, graph, entryPoints, rootDir) {
|
|
|
534
617
|
chains.set(root, chain);
|
|
535
618
|
}
|
|
536
619
|
const liveFiles = new Set(allFiles.filter((f) => !deadSet.has(f)));
|
|
537
|
-
const deadExports = findDeadExports(project, liveFiles);
|
|
620
|
+
const deadExports = findDeadExports(project, liveFiles, rootDir);
|
|
538
621
|
const report = buildDeadCodeReport(
|
|
539
622
|
safeToDelete,
|
|
540
623
|
transitivelyDead,
|
|
@@ -569,10 +652,12 @@ function collectTransitiveChain(root, graph, deadSet) {
|
|
|
569
652
|
}
|
|
570
653
|
return chain;
|
|
571
654
|
}
|
|
572
|
-
function findDeadExports(project, liveFiles) {
|
|
655
|
+
function findDeadExports(project, liveFiles, rootDir) {
|
|
573
656
|
const importedNames = buildImportedNameMap(project, liveFiles);
|
|
574
657
|
const dead = [];
|
|
575
658
|
for (const filePath of liveFiles) {
|
|
659
|
+
if (isFrameworkFile(filePath, rootDir)) continue;
|
|
660
|
+
if (isConventionRoutedSourceFile(filePath, rootDir)) continue;
|
|
576
661
|
collectFileDeadExports(filePath, project, importedNames, dead);
|
|
577
662
|
}
|
|
578
663
|
return dead;
|
|
@@ -600,62 +685,136 @@ function getFileSize(filePath) {
|
|
|
600
685
|
return 0;
|
|
601
686
|
}
|
|
602
687
|
}
|
|
688
|
+
var RULE = "\u2500".repeat(78);
|
|
689
|
+
var RULE2 = "\u2550".repeat(78);
|
|
603
690
|
function buildDeadCodeReport(safeToDelete, transitivelyDead, chains, deadExports, rootDir) {
|
|
604
691
|
const rel = (p) => import_node_path5.default.relative(rootDir, p).replaceAll("\\", "/");
|
|
605
|
-
const
|
|
606
|
-
const
|
|
607
|
-
const
|
|
692
|
+
const sortPaths = (paths) => [...paths].sort((a, b) => rel(a).localeCompare(rel(b), "en"));
|
|
693
|
+
const safeSorted = sortPaths(safeToDelete);
|
|
694
|
+
const transSorted = sortPaths(transitivelyDead);
|
|
695
|
+
const safeBytes = safeSorted.reduce((s, f) => s + getFileSize(f), 0);
|
|
696
|
+
const transBytes = transSorted.reduce((s, f) => s + getFileSize(f), 0);
|
|
697
|
+
const fileDeleteBytes = safeBytes + transBytes;
|
|
698
|
+
const totalKb = (fileDeleteBytes / 1024).toFixed(1);
|
|
699
|
+
const deadExportFiles = new Set(deadExports.map((e) => e.filePath));
|
|
700
|
+
const deadExportFileBytes = [...deadExportFiles].reduce((s, f) => s + getFileSize(f), 0);
|
|
608
701
|
const lines = [
|
|
609
|
-
|
|
702
|
+
RULE2,
|
|
610
703
|
" DEAD CODE REPORT",
|
|
704
|
+
RULE2,
|
|
611
705
|
` Safe to delete : ${safeToDelete.length}`,
|
|
612
706
|
` Transitively dead : ${transitivelyDead.length}`,
|
|
613
707
|
` Dead exports : ${deadExports.length}`,
|
|
614
|
-
` Recoverable
|
|
615
|
-
|
|
708
|
+
` Recoverable (files): ~${totalKb} KB (${formatBytes(fileDeleteBytes)})`,
|
|
709
|
+
RULE2,
|
|
710
|
+
"",
|
|
711
|
+
" HOW TO READ THIS",
|
|
712
|
+
"",
|
|
713
|
+
" 1. SAFE TO DELETE \u2014 No other source file imports these. You can delete them",
|
|
714
|
+
" first (after review).",
|
|
715
|
+
" 2. TRANSITIVELY DEAD \u2014 Only referenced by files that are already dead. After",
|
|
716
|
+
" removing safe-to-delete files, these become removable too.",
|
|
717
|
+
" 3. DEAD EXPORTS \u2014 The file is still used, but the listed export is never",
|
|
718
|
+
" imported by name. Remove the export (or use it) to tidy the API.",
|
|
719
|
+
" Framework routes (pages/, app/, etc.) are not listed here.",
|
|
720
|
+
"",
|
|
721
|
+
RULE,
|
|
616
722
|
""
|
|
617
723
|
];
|
|
618
|
-
if (
|
|
724
|
+
if (safeSorted.length > 0) {
|
|
619
725
|
lines.push(
|
|
620
726
|
"\u2500\u2500 SAFE TO DELETE \u2500\u2500",
|
|
621
|
-
"
|
|
727
|
+
"",
|
|
728
|
+
" Nothing in the project imports these paths. Review, then delete.",
|
|
622
729
|
""
|
|
623
730
|
);
|
|
624
|
-
for (const filePath of
|
|
731
|
+
for (const filePath of safeSorted) {
|
|
625
732
|
const chain = chains.get(filePath) ?? [];
|
|
626
|
-
const
|
|
627
|
-
lines.push(` ${rel(filePath)} (~${
|
|
733
|
+
const kb = (getFileSize(filePath) / 1024).toFixed(1);
|
|
734
|
+
lines.push(` \u2022 ${rel(filePath)} (~${kb} KB)`);
|
|
628
735
|
if (chain.length > 0) {
|
|
629
|
-
lines.push(
|
|
736
|
+
lines.push(
|
|
737
|
+
` \u21B3 Also becomes orphaned if you delete the line above:`,
|
|
738
|
+
` ${chain.map(rel).join(", ")}`
|
|
739
|
+
);
|
|
630
740
|
}
|
|
631
741
|
}
|
|
632
|
-
lines.push(
|
|
742
|
+
lines.push(
|
|
743
|
+
"",
|
|
744
|
+
` SUBTOTAL \u2014 ${safeSorted.length} file(s) \xB7 ${formatBytes(safeBytes)}`,
|
|
745
|
+
"",
|
|
746
|
+
RULE,
|
|
747
|
+
""
|
|
748
|
+
);
|
|
633
749
|
}
|
|
634
|
-
if (
|
|
750
|
+
if (transSorted.length > 0) {
|
|
635
751
|
lines.push(
|
|
636
752
|
"\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500",
|
|
637
|
-
"
|
|
753
|
+
"",
|
|
754
|
+
" Every importer of these files is already in the dead set above.",
|
|
638
755
|
""
|
|
639
756
|
);
|
|
640
|
-
for (const filePath of
|
|
641
|
-
const
|
|
642
|
-
lines.push(` ${rel(filePath)} (~${
|
|
757
|
+
for (const filePath of transSorted) {
|
|
758
|
+
const kb = (getFileSize(filePath) / 1024).toFixed(1);
|
|
759
|
+
lines.push(` \u2022 ${rel(filePath)} (~${kb} KB)`);
|
|
643
760
|
}
|
|
644
|
-
lines.push(
|
|
761
|
+
lines.push(
|
|
762
|
+
"",
|
|
763
|
+
` SUBTOTAL \u2014 ${transSorted.length} file(s) \xB7 ${formatBytes(transBytes)}`,
|
|
764
|
+
"",
|
|
765
|
+
RULE,
|
|
766
|
+
""
|
|
767
|
+
);
|
|
645
768
|
}
|
|
646
769
|
if (deadExports.length > 0) {
|
|
647
770
|
lines.push(
|
|
648
771
|
"\u2500\u2500 DEAD EXPORTS \u2500\u2500",
|
|
649
|
-
"
|
|
772
|
+
"",
|
|
773
|
+
" Symbol is exported but never imported by name elsewhere (namespace imports",
|
|
774
|
+
" count as \u201Call used\u201D). Route and framework files are excluded.",
|
|
650
775
|
""
|
|
651
776
|
);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
);
|
|
777
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
778
|
+
for (const e of deadExports) {
|
|
779
|
+
if (!byFile.has(e.filePath)) byFile.set(e.filePath, []);
|
|
780
|
+
byFile.get(e.filePath).push(e);
|
|
781
|
+
}
|
|
782
|
+
const fileKeys = [...byFile.keys()].sort((a, b) => rel(a).localeCompare(rel(b), "en"));
|
|
783
|
+
for (const fp of fileKeys) {
|
|
784
|
+
lines.push(` ${rel(fp)}`);
|
|
785
|
+
for (const entry of byFile.get(fp)) {
|
|
786
|
+
lines.push(` \u2022 export \u201C${entry.exportName}\u201D (line ${entry.line})`);
|
|
787
|
+
}
|
|
788
|
+
lines.push("");
|
|
656
789
|
}
|
|
657
|
-
lines.push(
|
|
790
|
+
lines.push(
|
|
791
|
+
` SUBTOTAL \u2014 ${deadExports.length} export(s) across ${deadExportFiles.size} file(s)`,
|
|
792
|
+
"",
|
|
793
|
+
RULE,
|
|
794
|
+
""
|
|
795
|
+
);
|
|
658
796
|
}
|
|
797
|
+
lines.push(
|
|
798
|
+
RULE2,
|
|
799
|
+
" END OF REPORT \u2014 DISK SPACE & CLEANUP SUMMARY",
|
|
800
|
+
RULE2,
|
|
801
|
+
"",
|
|
802
|
+
" If you DELETE every file under \u201CSafe to delete\u201D and \u201CTransitively dead\u201D:",
|
|
803
|
+
"",
|
|
804
|
+
` Files removed : ${safeSorted.length + transSorted.length}`,
|
|
805
|
+
` Disk freed : ${formatBytes(fileDeleteBytes)} (approx., from file sizes on disk)`,
|
|
806
|
+
"",
|
|
807
|
+
" Dead exports do not remove whole files by themselves. Cleaning them saves",
|
|
808
|
+
` maintainability first; the ${deadExportFiles.size} file(s) that contain them`,
|
|
809
|
+
` total about ${formatBytes(deadExportFileBytes)} on disk (entire files, not`,
|
|
810
|
+
" \u201Cexport-only\u201D savings).",
|
|
811
|
+
"",
|
|
812
|
+
" Suggested order: (1) Delete safe-to-delete files \u2192 (2) Delete transitively",
|
|
813
|
+
" dead files \u2192 (3) Remove or use dead exports in remaining files.",
|
|
814
|
+
"",
|
|
815
|
+
RULE2,
|
|
816
|
+
""
|
|
817
|
+
);
|
|
659
818
|
return lines.join("\n");
|
|
660
819
|
}
|
|
661
820
|
async function runDeadCode(dir, opts) {
|
|
@@ -1205,22 +1364,38 @@ async function runAssetCheck(rootDir, opts) {
|
|
|
1205
1364
|
writeMarkdown(
|
|
1206
1365
|
{
|
|
1207
1366
|
title: "Unused Assets Report",
|
|
1208
|
-
summary: `${result.unusedAssets.length} unused
|
|
1367
|
+
summary: `${result.unusedAssets.length} unused \xB7 ${formatBytes(result.unusedAssets.reduce((s, a) => s + a.sizeBytes, 0))} recoverable \u2014 see companion .txt for full detail`,
|
|
1209
1368
|
sections: [
|
|
1210
1369
|
{
|
|
1211
1370
|
title: "Unused Assets",
|
|
1212
1371
|
headers: ["Asset", "Size (KB)"],
|
|
1213
|
-
rows: result.unusedAssets.map((a) => [
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1372
|
+
rows: result.unusedAssets.slice().sort((a, b) => a.relativePath.localeCompare(b.relativePath, "en")).map((a) => [a.relativePath, (a.sizeBytes / 1024).toFixed(1)])
|
|
1373
|
+
},
|
|
1374
|
+
{
|
|
1375
|
+
title: "If you delete all listed files",
|
|
1376
|
+
rows: [
|
|
1377
|
+
[
|
|
1378
|
+
"Files removed",
|
|
1379
|
+
String(result.unusedAssets.length)
|
|
1380
|
+
],
|
|
1381
|
+
[
|
|
1382
|
+
"Approx. disk freed",
|
|
1383
|
+
formatBytes(result.unusedAssets.reduce((s, a) => s + a.sizeBytes, 0))
|
|
1384
|
+
]
|
|
1385
|
+
]
|
|
1217
1386
|
}
|
|
1218
1387
|
],
|
|
1219
1388
|
generatedAt: /* @__PURE__ */ new Date()
|
|
1220
1389
|
},
|
|
1221
1390
|
opts.output
|
|
1222
1391
|
);
|
|
1223
|
-
|
|
1392
|
+
const txtPath = import_node_path8.default.join(
|
|
1393
|
+
import_node_path8.default.dirname(opts.output),
|
|
1394
|
+
`${import_node_path8.default.basename(opts.output, import_node_path8.default.extname(opts.output))}.txt`
|
|
1395
|
+
);
|
|
1396
|
+
import_node_fs8.default.writeFileSync(txtPath, result.report, "utf-8");
|
|
1397
|
+
console.log(import_chalk6.default.cyan(` Reports written \u2192 ${opts.output}`));
|
|
1398
|
+
console.log(import_chalk6.default.cyan(` \u2192 ${txtPath}`));
|
|
1224
1399
|
}
|
|
1225
1400
|
return result.unusedAssets;
|
|
1226
1401
|
} catch (err) {
|
|
@@ -1258,32 +1433,163 @@ function getFileSize2(filePath) {
|
|
|
1258
1433
|
}
|
|
1259
1434
|
function buildAssetReport(unused, totalAssets, rootDir) {
|
|
1260
1435
|
const totalBytes = unused.reduce((s, a) => s + a.sizeBytes, 0);
|
|
1261
|
-
const
|
|
1436
|
+
const usedCount = totalAssets - unused.length;
|
|
1437
|
+
const pct = totalAssets > 0 ? (unused.length / totalAssets * 100).toFixed(1) : "0.0";
|
|
1438
|
+
const RULE22 = "\u2550".repeat(78);
|
|
1439
|
+
const RULE3 = "\u2500".repeat(78);
|
|
1262
1440
|
const lines = [
|
|
1263
|
-
|
|
1264
|
-
" UNUSED ASSETS REPORT",
|
|
1265
|
-
|
|
1266
|
-
`
|
|
1267
|
-
`
|
|
1268
|
-
|
|
1441
|
+
RULE22,
|
|
1442
|
+
" UNUSED ASSETS REPORT (public/)",
|
|
1443
|
+
RULE22,
|
|
1444
|
+
` Total files scanned : ${totalAssets}`,
|
|
1445
|
+
` Referenced in code : ${usedCount}`,
|
|
1446
|
+
` Unused (listed) : ${unused.length} (${pct}% of scanned)`,
|
|
1447
|
+
` Disk size (unused) : ${formatBytes(totalBytes)}`,
|
|
1448
|
+
RULE22,
|
|
1449
|
+
"",
|
|
1450
|
+
" HOW TO READ THIS",
|
|
1451
|
+
"",
|
|
1452
|
+
" Files below live under public/ but their filename or public URL path was not",
|
|
1453
|
+
" found in your source scan (TS, JS, CSS, HTML, JSON). They may still be used",
|
|
1454
|
+
" if loaded by URL from a CMS, CDN, or runtime string \u2014 verify before deleting.",
|
|
1455
|
+
"",
|
|
1456
|
+
RULE3,
|
|
1269
1457
|
""
|
|
1270
1458
|
];
|
|
1271
1459
|
if (unused.length === 0) {
|
|
1272
|
-
lines.push(" All public assets are referenced in source.", "");
|
|
1273
|
-
return lines.join("\n");
|
|
1274
|
-
}
|
|
1275
|
-
lines.push("\u2500\u2500 UNUSED ASSETS \u2500\u2500", "");
|
|
1276
|
-
for (const asset of unused) {
|
|
1277
1460
|
lines.push(
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1461
|
+
" No unused assets detected \u2014 everything scanned appears referenced.",
|
|
1462
|
+
"",
|
|
1463
|
+
RULE22,
|
|
1464
|
+
" END OF REPORT",
|
|
1465
|
+
RULE22,
|
|
1466
|
+
"",
|
|
1467
|
+
" If you removed nothing: disk savings = 0 (already optimal for this scan).",
|
|
1468
|
+
"",
|
|
1469
|
+
RULE22,
|
|
1281
1470
|
""
|
|
1282
1471
|
);
|
|
1472
|
+
return lines.join("\n");
|
|
1473
|
+
}
|
|
1474
|
+
const sorted = [...unused].sort(
|
|
1475
|
+
(a, b) => a.relativePath.localeCompare(b.relativePath, "en")
|
|
1476
|
+
);
|
|
1477
|
+
lines.push("\u2500\u2500 UNUSED ASSET FILES \u2500\u2500", "", " Sorted by path.", "");
|
|
1478
|
+
for (const asset of sorted) {
|
|
1479
|
+
const kb = (asset.sizeBytes / 1024).toFixed(1);
|
|
1480
|
+
lines.push(` \u2022 ${asset.relativePath} (~${kb} KB)`);
|
|
1481
|
+
lines.push(` On disk: ${formatBytes(asset.sizeBytes)}`);
|
|
1482
|
+
lines.push("");
|
|
1283
1483
|
}
|
|
1484
|
+
lines.push(
|
|
1485
|
+
` SUBTOTAL \u2014 ${sorted.length} file(s) \xB7 ${formatBytes(totalBytes)}`,
|
|
1486
|
+
"",
|
|
1487
|
+
RULE3,
|
|
1488
|
+
"",
|
|
1489
|
+
RULE22,
|
|
1490
|
+
" END OF REPORT \u2014 DISK SPACE SUMMARY",
|
|
1491
|
+
RULE22,
|
|
1492
|
+
"",
|
|
1493
|
+
" If you DELETE every unused asset listed above from public/:",
|
|
1494
|
+
"",
|
|
1495
|
+
` Files removed : ${sorted.length}`,
|
|
1496
|
+
` Disk freed : ${formatBytes(totalBytes)} (sum of file sizes on disk)`,
|
|
1497
|
+
"",
|
|
1498
|
+
" This does not include savings from dead code \u2014 see dead-code.txt for source files.",
|
|
1499
|
+
"",
|
|
1500
|
+
RULE22,
|
|
1501
|
+
""
|
|
1502
|
+
);
|
|
1284
1503
|
return lines.join("\n");
|
|
1285
1504
|
}
|
|
1286
1505
|
|
|
1506
|
+
// src/core/agent-prompt.ts
|
|
1507
|
+
function buildPromptBody(meta) {
|
|
1508
|
+
const { prunifyVersion, generatedAtIso, projectRootDisplay, reportsDirDisplay } = meta;
|
|
1509
|
+
return `You are assisting with a **careful, incremental** cleanup of a TypeScript/JavaScript codebase.
|
|
1510
|
+
|
|
1511
|
+
## Context (prunify ${prunifyVersion})
|
|
1512
|
+
|
|
1513
|
+
- **Project root:** ${projectRootDisplay}
|
|
1514
|
+
- **Reports folder:** ${reportsDirDisplay}
|
|
1515
|
+
- **Report generated (this prompt file):** ${generatedAtIso}
|
|
1516
|
+
|
|
1517
|
+
The user has run **prunify** (static analysis). The reports are hints, not ground truth. **False positives are common.**
|
|
1518
|
+
|
|
1519
|
+
## Attach these files to the chat (if they exist)
|
|
1520
|
+
|
|
1521
|
+
From \`${reportsDirDisplay}\`:
|
|
1522
|
+
|
|
1523
|
+
| File | What it means |
|
|
1524
|
+
|------|----------------|
|
|
1525
|
+
| **dead-code.txt** | Orphan files (safe / transitive), dead exports, sizes. **--delete** uses this for whole-file removal. |
|
|
1526
|
+
| **circular.txt** | Import cycles \u2014 usually **refactor**, not delete. |
|
|
1527
|
+
| **dupes.md** | Repeated code **blocks** \u2014 merge only after **semantic** review. |
|
|
1528
|
+
| **deps.md** | package.json vs imports \u2014 **unused** / **missing** / dev issues. |
|
|
1529
|
+
| **assets.md** (+ **assets.txt**) | Unreferenced files under **public/** \u2014 **--delete-assets** uses the report. |
|
|
1530
|
+
|
|
1531
|
+
If a file is missing, skip that section of work.
|
|
1532
|
+
|
|
1533
|
+
---
|
|
1534
|
+
|
|
1535
|
+
## Mandatory safety rules (do not skip)
|
|
1536
|
+
|
|
1537
|
+
1. **No bulk deletes.** Work in small batches; one PR or one chat turn per logical change.
|
|
1538
|
+
2. **Dead code \u2014 files:** Only delete files listed under **Safe to delete** (or explicitly marked removable in dead-code.txt) **after** you grep/search for dynamic imports, \`require(variable)\`, string-based paths, Next.js \`app/\`/\`pages/\` conventions, and config references.
|
|
1539
|
+
3. **Dead code \u2014 exports:** Removing an export can break external consumers or barrels. After removal, run typecheck; fix barrel \`index.ts\` re-exports.
|
|
1540
|
+
4. **Barrel files (\`index.ts\`):** A file may be "used" only via \`export * from './X'\`. Do not delete leaf modules until importers and barrels are updated.
|
|
1541
|
+
5. **Transitively dead:** Deletion order matters \u2014 follow the report chains; remove importers before importers-only-used-by-dead-code where applicable.
|
|
1542
|
+
6. **Circular imports:** Prefer extracting shared code or inverting dependencies; **do not** "fix" by deleting arbitrary cycle members.
|
|
1543
|
+
7. **dupes.md:** Identical text \u2260 identical behavior (closures, imports, generics). **Merge duplicates only** when you can prove equivalence; prefer extracting a shared function with tests.
|
|
1544
|
+
8. **deps.md \u2014 unused:** prunify scans **src/** imports only. Packages may still be used in **root config**, **scripts**, **CSS**, **tests outside src**, **Next.js** files outside \`src/\`, or **CLI**. Confirm with repo-wide search before \`npm uninstall\`.
|
|
1545
|
+
9. **deps.md \u2014 missing:** May be transitive or tooling; verify before adding duplicates.
|
|
1546
|
+
10. **assets:** URLs may be built at runtime, come from CMS, or live in strings prunify does not see. Confirm with search before deleting from **public/**.
|
|
1547
|
+
|
|
1548
|
+
---
|
|
1549
|
+
|
|
1550
|
+
## Suggested order (lowest risk first)
|
|
1551
|
+
|
|
1552
|
+
1. **Verify deps.md** \u2014 search the whole repo; adjust package.json only with evidence.
|
|
1553
|
+
2. **Dead exports** \u2014 remove unused exports / tidy barrels; typecheck after each batch.
|
|
1554
|
+
3. **Safe-to-delete files** \u2014 small batches; run build after.
|
|
1555
|
+
4. **Unused assets** \u2014 after string search in app and config.
|
|
1556
|
+
5. **dupes.md** \u2014 last; highest judgment; add tests when merging.
|
|
1557
|
+
|
|
1558
|
+
---
|
|
1559
|
+
|
|
1560
|
+
## After every batch
|
|
1561
|
+
|
|
1562
|
+
- Run **lint** and **typecheck** (and **tests** if available).
|
|
1563
|
+
- Run **production build** before declaring done.
|
|
1564
|
+
- If something fails, **revert** that batch and narrow the change.
|
|
1565
|
+
|
|
1566
|
+
---
|
|
1567
|
+
|
|
1568
|
+
## What not to do
|
|
1569
|
+
|
|
1570
|
+
- Do not delete **pages**, **app routes**, **middleware**, or framework entry files unless the user explicitly asks and you verified they are unused.
|
|
1571
|
+
- Do not remove packages because deps.md says "unused" without a repo-wide check.
|
|
1572
|
+
- Do not merge duplicate blocks from dupes.md without reading surrounding code.
|
|
1573
|
+
|
|
1574
|
+
When uncertain, **list the risk** and ask the user to confirm before editing.`;
|
|
1575
|
+
}
|
|
1576
|
+
function buildAiPromptTsx(meta) {
|
|
1577
|
+
const body = buildPromptBody(meta);
|
|
1578
|
+
const literal = JSON.stringify(body);
|
|
1579
|
+
return `/* eslint-disable */
|
|
1580
|
+
/**
|
|
1581
|
+
* Auto-generated by prunify ${meta.prunifyVersion} \u2014 ${meta.generatedAtIso}
|
|
1582
|
+
* Do not edit by hand; re-run \`npx prunify\` to refresh.
|
|
1583
|
+
*
|
|
1584
|
+
* **For Cursor / Copilot:** attach the files listed inside PRUNIFY_CLEANUP_AGENT_PROMPT
|
|
1585
|
+
* (from prunify-reports/), then paste the prompt or use @-mention on those files plus this file.
|
|
1586
|
+
*/
|
|
1587
|
+
export const PRUNIFY_CLEANUP_AGENT_PROMPT = ${literal} as const
|
|
1588
|
+
|
|
1589
|
+
export default PRUNIFY_CLEANUP_AGENT_PROMPT
|
|
1590
|
+
`;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1287
1593
|
// src/core/report-parser.ts
|
|
1288
1594
|
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
1289
1595
|
var import_node_path9 = __toESM(require("path"), 1);
|
|
@@ -1305,7 +1611,7 @@ function parseDeadCodeReportContent(content, rootDir) {
|
|
|
1305
1611
|
return [...new Set(paths)];
|
|
1306
1612
|
}
|
|
1307
1613
|
let section = "none";
|
|
1308
|
-
const fileLineRe = /^\s
|
|
1614
|
+
const fileLineRe = /^\s*(?:•\s+)?(.+?)\s+\(~[\d.]+\s+KB\)\s*$/;
|
|
1309
1615
|
for (const line of lines) {
|
|
1310
1616
|
if (line.includes("\u2500\u2500 SAFE TO DELETE \u2500\u2500")) {
|
|
1311
1617
|
section = "safe";
|
|
@@ -1326,7 +1632,9 @@ function parseDeadCodeReportContent(content, rootDir) {
|
|
|
1326
1632
|
continue;
|
|
1327
1633
|
}
|
|
1328
1634
|
if (section === "safe" || section === "transitive") {
|
|
1329
|
-
if (line.includes("\u2514\u2500") || line.includes("also makes dead"))
|
|
1635
|
+
if (line.includes("\u2514\u2500") || line.includes("\u21B3") || line.includes("also makes dead") || line.includes("SUBTOTAL") || line.includes("\u2500\u2500\u2500")) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1330
1638
|
const m = fileLineRe.exec(line);
|
|
1331
1639
|
if (m) {
|
|
1332
1640
|
const rel = m[1].trim();
|
|
@@ -1347,10 +1655,23 @@ function countDeadExportsInReport(content) {
|
|
|
1347
1655
|
continue;
|
|
1348
1656
|
}
|
|
1349
1657
|
if (section && line.startsWith("\u2500\u2500 ") && line.includes("\u2500\u2500")) break;
|
|
1350
|
-
if (section && /\
|
|
1658
|
+
if (section && /\(line \d+\)/.test(line)) n++;
|
|
1351
1659
|
}
|
|
1352
1660
|
return n;
|
|
1353
1661
|
}
|
|
1662
|
+
function parseDeadCodeReportSummary(content) {
|
|
1663
|
+
const safe = content.match(/Safe to delete\s*:\s*(\d+)/);
|
|
1664
|
+
const trans = content.match(/Transitively dead\s*:\s*(\d+)/);
|
|
1665
|
+
const exp = content.match(/Dead exports\s*:\s*(\d+)/);
|
|
1666
|
+
const rec = content.match(/Recoverable[^:\n]*:\s*(~[\d.]+\s*KB)/i);
|
|
1667
|
+
if (!safe || !trans || !exp) return null;
|
|
1668
|
+
return {
|
|
1669
|
+
safeToDelete: Number(safe[1]),
|
|
1670
|
+
transitivelyDead: Number(trans[1]),
|
|
1671
|
+
deadExports: Number(exp[1]),
|
|
1672
|
+
recoverableDisplay: rec ? rec[1].trim() : "~0.0 KB"
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1354
1675
|
function parseAssetsReportFile(reportPath, rootDir) {
|
|
1355
1676
|
if (!import_node_fs9.default.existsSync(reportPath)) return [];
|
|
1356
1677
|
const content = import_node_fs9.default.readFileSync(reportPath, "utf-8");
|
|
@@ -1479,45 +1800,61 @@ async function main(opts) {
|
|
|
1479
1800
|
entryPoints = opts.entry ? [import_node_path10.default.resolve(opts.entry)] : findEntryPoints(rootDir, packageJson);
|
|
1480
1801
|
}
|
|
1481
1802
|
console.log();
|
|
1482
|
-
let
|
|
1803
|
+
let deadSafeCount = 0;
|
|
1804
|
+
let deadTransitiveCount = 0;
|
|
1805
|
+
let deadExportsCount = 0;
|
|
1806
|
+
let deadRecoverDisplay = "~0.0 KB";
|
|
1483
1807
|
let dupeCount = 0;
|
|
1484
1808
|
let unusedPkgCount = 0;
|
|
1485
1809
|
let circularCount = 0;
|
|
1486
1810
|
let unusedAssetCount = 0;
|
|
1487
|
-
let deadReportFile = "";
|
|
1488
1811
|
let dupesReportFile = "";
|
|
1489
1812
|
let depsReportFile = "";
|
|
1490
1813
|
let circularReportFile = "";
|
|
1491
1814
|
let assetsReportFile = "";
|
|
1492
1815
|
if (loadDeadFromCache) {
|
|
1493
|
-
deadReportFile = "dead-code.txt";
|
|
1494
1816
|
try {
|
|
1495
1817
|
const raw = import_node_fs10.default.readFileSync(deadReportPath, "utf-8");
|
|
1496
|
-
|
|
1818
|
+
const parsed = parseDeadCodeReportSummary(raw);
|
|
1819
|
+
if (parsed) {
|
|
1820
|
+
deadSafeCount = parsed.safeToDelete;
|
|
1821
|
+
deadTransitiveCount = parsed.transitivelyDead;
|
|
1822
|
+
deadExportsCount = parsed.deadExports;
|
|
1823
|
+
deadRecoverDisplay = parsed.recoverableDisplay;
|
|
1824
|
+
} else {
|
|
1825
|
+
deadExportsCount = countDeadExportsInReport(raw);
|
|
1826
|
+
deadSafeCount = deadFilePaths.length;
|
|
1827
|
+
deadTransitiveCount = 0;
|
|
1828
|
+
deadRecoverDisplay = "\u2014";
|
|
1829
|
+
}
|
|
1497
1830
|
} catch {
|
|
1498
|
-
|
|
1831
|
+
deadSafeCount = deadFilePaths.length;
|
|
1499
1832
|
}
|
|
1500
1833
|
}
|
|
1501
1834
|
if (loadAssetsFromCache) {
|
|
1502
|
-
|
|
1835
|
+
const assetsTxtPath = import_node_path10.default.join(reportsDir, "assets.txt");
|
|
1836
|
+
assetsReportFile = import_node_fs10.default.existsSync(assetsTxtPath) ? "assets.md, assets.txt" : "assets.md";
|
|
1503
1837
|
unusedAssetCount = unusedAssetPaths.length;
|
|
1504
1838
|
}
|
|
1505
1839
|
if (runDeadCode2) {
|
|
1506
1840
|
const spinner = createSpinner(import_chalk7.default.cyan("Analysing dead code\u2026"));
|
|
1507
1841
|
const result = runDeadCodeModule(project, graph, entryPoints, rootDir);
|
|
1508
|
-
deadFileCount = result.safeToDelete.length + result.transitivelyDead.length + result.deadExports.length;
|
|
1509
1842
|
deadFilePaths = [...result.safeToDelete, ...result.transitivelyDead];
|
|
1843
|
+
deadSafeCount = result.safeToDelete.length;
|
|
1844
|
+
deadTransitiveCount = result.transitivelyDead.length;
|
|
1845
|
+
deadExportsCount = result.deadExports.length;
|
|
1846
|
+
const recoverBytes = deadFilePaths.reduce((sum, f) => sum + getFileSize(f), 0);
|
|
1847
|
+
deadRecoverDisplay = `~${(recoverBytes / 1024).toFixed(1)} KB`;
|
|
1510
1848
|
spinner.succeed(
|
|
1511
1849
|
import_chalk7.default.green(
|
|
1512
1850
|
`Dead code analysis complete \u2014 ${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`
|
|
1513
1851
|
)
|
|
1514
1852
|
);
|
|
1515
1853
|
if (result.report) {
|
|
1516
|
-
deadReportFile = "dead-code.txt";
|
|
1517
1854
|
const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1518
1855
|
|
|
1519
1856
|
`;
|
|
1520
|
-
writeReport(reportsDir,
|
|
1857
|
+
writeReport(reportsDir, "dead-code.txt", banner + result.report);
|
|
1521
1858
|
}
|
|
1522
1859
|
}
|
|
1523
1860
|
if (modules.includes("dupes")) {
|
|
@@ -1568,7 +1905,7 @@ async function main(opts) {
|
|
|
1568
1905
|
const unusedAssets = await runAssetCheck(rootDir, { output: outputPath });
|
|
1569
1906
|
unusedAssetPaths = unusedAssets.map((a) => a.filePath);
|
|
1570
1907
|
unusedAssetCount = unusedAssets.length;
|
|
1571
|
-
if (unusedAssetCount > 0) assetsReportFile = "assets.md";
|
|
1908
|
+
if (unusedAssetCount > 0) assetsReportFile = "assets.md, assets.txt";
|
|
1572
1909
|
}
|
|
1573
1910
|
if (modules.includes("health")) {
|
|
1574
1911
|
const outputPath = import_node_path10.default.join(reportsDir, "health-report.md");
|
|
@@ -1582,13 +1919,25 @@ async function main(opts) {
|
|
|
1582
1919
|
console.log();
|
|
1583
1920
|
console.log(import_chalk7.default.bold("Summary"));
|
|
1584
1921
|
console.log();
|
|
1922
|
+
const fmt = (n) => n > 0 ? import_chalk7.default.yellow(String(n)) : import_chalk7.default.green("0");
|
|
1923
|
+
const showDeadSummary = modules.includes("dead-code") || Boolean(opts.delete) || loadDeadFromCache || runDeadCode2;
|
|
1924
|
+
if (showDeadSummary) {
|
|
1925
|
+
const recoverKb = parseFloat(deadRecoverDisplay.replace(/[^\d.]/g, "")) || 0;
|
|
1926
|
+
const recoverCh = deadRecoverDisplay === "\u2014" ? import_chalk7.default.dim("\u2014") : recoverKb > 0 ? import_chalk7.default.yellow(deadRecoverDisplay) : import_chalk7.default.green(deadRecoverDisplay);
|
|
1927
|
+
console.log(import_chalk7.default.dim("========================================"));
|
|
1928
|
+
console.log(import_chalk7.default.dim(" DEAD CODE REPORT"));
|
|
1929
|
+
console.log(import_chalk7.default.dim(" Safe to delete : ") + fmt(deadSafeCount));
|
|
1930
|
+
console.log(import_chalk7.default.dim(" Transitively dead : ") + fmt(deadTransitiveCount));
|
|
1931
|
+
console.log(import_chalk7.default.dim(" Dead exports : ") + fmt(deadExportsCount));
|
|
1932
|
+
console.log(import_chalk7.default.dim(" Recoverable : ") + recoverCh);
|
|
1933
|
+
console.log(import_chalk7.default.dim("========================================"));
|
|
1934
|
+
console.log();
|
|
1935
|
+
}
|
|
1585
1936
|
const table = new import_cli_table36.default({
|
|
1586
1937
|
head: [import_chalk7.default.bold("Check"), import_chalk7.default.bold("Found"), import_chalk7.default.bold("Output File")],
|
|
1587
1938
|
style: { head: [], border: [] }
|
|
1588
1939
|
});
|
|
1589
|
-
const fmt = (n) => n > 0 ? import_chalk7.default.yellow(String(n)) : import_chalk7.default.green("0");
|
|
1590
1940
|
table.push(
|
|
1591
|
-
["Dead Code (files + exports)", fmt(deadFileCount), deadReportFile || "\u2014"],
|
|
1592
1941
|
["Circular Dependencies", fmt(circularCount), circularReportFile || "\u2014"],
|
|
1593
1942
|
["Duplicate Clusters", fmt(dupeCount), dupesReportFile || "\u2014"],
|
|
1594
1943
|
["Unused Packages", fmt(unusedPkgCount), depsReportFile || "\u2014"],
|
|
@@ -1596,6 +1945,16 @@ async function main(opts) {
|
|
|
1596
1945
|
);
|
|
1597
1946
|
console.log(table.toString());
|
|
1598
1947
|
console.log();
|
|
1948
|
+
const aiPromptContent = buildAiPromptTsx({
|
|
1949
|
+
prunifyVersion: PKG_VERSION,
|
|
1950
|
+
generatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1951
|
+
projectRootDisplay: import_node_path10.default.relative(process.cwd(), rootDir) || ".",
|
|
1952
|
+
reportsDirDisplay: import_node_path10.default.relative(rootDir, reportsDir).replaceAll("\\", "/") || "prunify-reports"
|
|
1953
|
+
});
|
|
1954
|
+
const aiPromptPath = import_node_path10.default.join(reportsDir, "ai_prompt.tsx");
|
|
1955
|
+
import_node_fs10.default.writeFileSync(aiPromptPath, aiPromptContent, "utf-8");
|
|
1956
|
+
console.log(import_chalk7.default.cyan(` Agent prompt saved \u2192 ${aiPromptPath}`));
|
|
1957
|
+
console.log();
|
|
1599
1958
|
if (opts.delete && deadFilePaths.length > 0) {
|
|
1600
1959
|
console.log(import_chalk7.default.yellow(`Dead code files to delete (${deadFilePaths.length}):`));
|
|
1601
1960
|
for (const f of deadFilePaths) {
|
|
@@ -1665,7 +2024,8 @@ async function main(opts) {
|
|
|
1665
2024
|
console.log(import_chalk7.default.dim(" --delete-assets: no unused assets in report (nothing to delete)."));
|
|
1666
2025
|
}
|
|
1667
2026
|
if (opts.ci) {
|
|
1668
|
-
const
|
|
2027
|
+
const deadIssueCount = deadSafeCount + deadTransitiveCount + deadExportsCount;
|
|
2028
|
+
const hasIssues = deadIssueCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0 || unusedAssetCount > 0;
|
|
1669
2029
|
if (hasIssues) process.exit(1);
|
|
1670
2030
|
}
|
|
1671
2031
|
}
|