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/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 allDeadFiles = [...safeToDelete, ...transitivelyDead];
606
- const totalBytes = allDeadFiles.reduce((sum, f) => sum + getFileSize(f), 0);
607
- const totalKb = (totalBytes / 1024).toFixed(1);
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 : ~${totalKb} KB`,
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 (safeToDelete.length > 0) {
724
+ if (safeSorted.length > 0) {
619
725
  lines.push(
620
726
  "\u2500\u2500 SAFE TO DELETE \u2500\u2500",
621
- "(These files are not imported by any other file in the codebase)",
727
+ "",
728
+ " Nothing in the project imports these paths. Review, then delete.",
622
729
  ""
623
730
  );
624
- for (const filePath of safeToDelete) {
731
+ for (const filePath of safeSorted) {
625
732
  const chain = chains.get(filePath) ?? [];
626
- const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
627
- lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
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(` \u2514\u2500 also makes dead: ${chain.map(rel).join(", ")}`);
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 (transitivelyDead.length > 0) {
750
+ if (transSorted.length > 0) {
635
751
  lines.push(
636
752
  "\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500",
637
- "(These files are only imported by dead files \u2014 they become orphaned too)",
753
+ "",
754
+ " Every importer of these files is already in the dead set above.",
638
755
  ""
639
756
  );
640
- for (const filePath of transitivelyDead) {
641
- const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
642
- lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
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
- "(Exported but never imported by any other file)",
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
- for (const entry of deadExports) {
653
- lines.push(
654
- ` ${rel(entry.filePath)} \u2192 ${entry.exportName} [line ${entry.line}]`
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 asset(s) found in public/`,
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
- a.relativePath,
1215
- (a.sizeBytes / 1024).toFixed(1)
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
- console.log(import_chalk6.default.cyan(` Report written to ${opts.output}`));
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 totalKb = (totalBytes / 1024).toFixed(1);
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
- ` Total assets : ${totalAssets}`,
1266
- ` Unused assets : ${unused.length}`,
1267
- ` Recoverable : ~${totalKb} KB`,
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
- `UNUSED \u2014 ${asset.relativePath}`,
1279
- `Size: ~${(asset.sizeBytes / 1024).toFixed(1)} KB`,
1280
- `Action: Safe to delete if not served directly via URL`,
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{2}(.+?)\s+\(~[\d.]+\s+KB\)\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")) continue;
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 && /\s→\s.+\[line \d+\]/.test(line)) n++;
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 deadFileCount = 0;
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
- deadFileCount = deadFilePaths.length + countDeadExportsInReport(raw);
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
- deadFileCount = deadFilePaths.length;
1831
+ deadSafeCount = deadFilePaths.length;
1499
1832
  }
1500
1833
  }
1501
1834
  if (loadAssetsFromCache) {
1502
- assetsReportFile = "assets.md";
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, deadReportFile, banner + result.report);
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 hasIssues = deadFileCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0 || unusedAssetCount > 0;
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
  }