prunify 0.1.5 β†’ 0.1.6

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 CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  | Module | What it finds |
30
30
  |--------|---------------|
31
- | πŸ—‘οΈ **Dead code** | Files and named exports that are never imported anywhere in the codebase |
31
+ | πŸ—‘οΈ **Dead code** | **Safe to delete** (nothing imports them), **transitively dead** (only dead files import them), and **dead exports** (live files with unused named exports) |
32
32
  | πŸ” **Duplicate code** | Functions with identical bodies, suspiciously similar names, and duplicate constants |
33
33
  | ♻️ **Circular imports** | Dependency cycles that can cause runtime bugs and bundler issues |
34
34
  | πŸ“¦ **Unused dependencies** | Packages declared in `package.json` that are never actually imported |
@@ -81,7 +81,7 @@ npx prunify --ci
81
81
  βœ” Parsed codebase β€” 142 file(s) found
82
82
  βœ” Import graph built β€” 389 edge(s)
83
83
 
84
- βœ” Dead code analysis complete β€” 7 item(s) found
84
+ βœ” Dead code analysis complete β€” 3 safe to delete, 1 transitively dead, 3 dead export(s)
85
85
  βœ” Duplicate scan complete β€” 3 duplicate block(s) found
86
86
  βœ” Circular import analysis complete β€” 2 cycle(s) found
87
87
  βœ” Dependency audit complete β€” 4 issue(s) found
@@ -89,15 +89,15 @@ npx prunify --ci
89
89
 
90
90
  Summary
91
91
 
92
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
93
- β”‚ Check β”‚ Found β”‚ Output File β”‚
94
- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
95
- β”‚ Dead Files / Exports β”‚ 7 β”‚ dead-code.txt β”‚
96
- β”‚ Duplicate Clusters β”‚ 3 β”‚ dupes.md β”‚
97
- β”‚ Unused Packages β”‚ 4 β”‚ deps.md β”‚
98
- β”‚ Circular Deps β”‚ 2 β”‚ circular.txt β”‚
99
- β”‚ Unused Assets β”‚ 5 β”‚ assets.md β”‚
100
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
92
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
93
+ β”‚ Check β”‚ Found β”‚ Output File β”‚
94
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
95
+ β”‚ Dead Code (files + exports) β”‚ 7 β”‚ dead-code.txt β”‚
96
+ β”‚ Circular Dependencies β”‚ 2 β”‚ circular.txt β”‚
97
+ β”‚ Duplicate Clusters β”‚ 3 β”‚ dupes.md β”‚
98
+ β”‚ Unused Packages β”‚ 4 β”‚ deps.md β”‚
99
+ β”‚ Unused Assets β”‚ 5 β”‚ assets.md β”‚
100
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
101
101
  ```
102
102
 
103
103
  ---
@@ -112,10 +112,23 @@ Summary
112
112
  | `--ignore <pattern>` | β€” | Glob pattern to exclude from analysis. Repeatable. |
113
113
  | `--out <path>` | `<dir>/prunify-reports/` | Directory to write report files to. |
114
114
  | `--html` | `false` | Generate `code_health.html` β€” a self-contained page with an SVG score gauge. |
115
- | `--delete` | `false` | After analysis, prompt to permanently delete dead files. |
116
- | `--ci` | `false` | Non-interactive mode. Exits `1` if any issues are found. |
115
+ | `--delete` | `false` | Prompt to permanently delete dead **code files** (safe to delete + transitively dead β€” not dead exports). See **Delete commands & cached reports** below. |
116
+ | `--delete-assets` | `false` | Prompt to delete unused files under `public/` from the asset report. Only paths inside `public/` are removed. See **Delete commands & cached reports** below. |
117
+ | `--ci` | `false` | Non-interactive mode. Exits `1` if any issues are found. With `--delete` / `--delete-assets`, does not prompt and does **not** delete files. |
117
118
  | `-v, --version` | β€” | Print the installed version. |
118
119
 
120
+ ### Delete commands & cached reports
121
+
122
+ When you use **`--delete`** or **`--delete-assets`**, prunify prefers your last report so you do not have to re-scan the whole repo every time:
123
+
124
+ | Flag | If the report already exists | If the report is missing |
125
+ |------|------------------------------|---------------------------|
126
+ | **`--delete`** | Reads file paths from `prunify-reports/dead-code.txt` (skips dead-code analysis and may skip building the import graph when no other module needs it). | Runs dead-code analysis, writes `dead-code.txt`, then prompts to delete. |
127
+ | **`--delete-assets`** | Reads paths from `prunify-reports/assets.md` (skips the asset scan). | Runs the asset scan, writes `assets.md`, then prompts to delete. |
128
+
129
+ - **`dead-code.txt`** starts with a line like `prunify <version> β€” <ISO timestamp>` so you know which run produced it.
130
+ - Keep your dependency on prunify up to date (e.g. `"prunify": "^0.1.5"` in `devDependencies`) so you get the latest detection logic; pinning an old exact version will not pick up fixes from npm.
131
+
119
132
  ### More examples
120
133
 
121
134
  ```bash
@@ -131,9 +144,18 @@ npx prunify --only health --html
131
144
  # Only check unused public assets
132
145
  npx prunify --only assets
133
146
 
134
- # Delete dead files after reviewing
147
+ # First run: analyse + write reports, then prompt to delete dead code files
135
148
  npx prunify --delete
136
149
 
150
+ # Later: delete from existing dead-code.txt only (no graph build if e.g. --only deps)
151
+ npx prunify --only deps --delete
152
+
153
+ # Unused public assets: scan + prompt to delete
154
+ npx prunify --only assets --delete-assets
155
+
156
+ # Reuse assets.md only (no asset scan)
157
+ npx prunify --delete-assets
158
+
137
159
  # Strict CI gate β€” fails the build if any issues exist
138
160
  npx prunify --ci
139
161
  ```
@@ -146,11 +168,11 @@ All reports are written to `prunify-reports/` (auto-added to `.gitignore`).
146
168
 
147
169
  | File | Contents |
148
170
  |------|----------|
149
- | `dead-code.txt` | Dead files and exports with chain analysis and recoverable byte count |
171
+ | `dead-code.txt` | Header with tool version + time, then **Safe to delete**, **Transitively dead**, and **Dead exports** sections (sizes and optional chains). Used by `--delete` when re-running deletes. |
150
172
  | `dupes.md` | Duplicate function clusters with AI-ready refactor prompts |
151
- | `circular.txt` | Circular dependency chains |
173
+ | `circular.txt` | Circular dependency chains (relative paths; header with tool version + time) |
152
174
  | `deps.md` | Unused packages |
153
- | `assets.md` | Unused files found in `public/` with sizes |
175
+ | `assets.md` | Unused files found in `public/` with sizes (Markdown table). Used by `--delete-assets` when re-running deletes. |
154
176
  | `health-report.md` | Combined report with health score (with `--only health`) |
155
177
  | `code_health.html` | Self-contained HTML page with SVG score gauge (with `--html`) |
156
178
 
@@ -158,16 +180,15 @@ All reports are written to `prunify-reports/` (auto-added to `.gitignore`).
158
180
 
159
181
  ## How Dead Code Detection Works
160
182
 
161
- prunify builds a directed import graph of your entire codebase, then runs a depth-first traversal starting from your entry points.
183
+ prunify builds a directed import graph (with `tsconfig.json` path aliases) and a **reverse** map of β€œwho imports this file”.
162
184
 
163
- **Entry point auto-detection order:**
185
+ 1. **Safe to delete** β€” No other project file imports this file, and it is not treated as an app entry (e.g. Next.js `pages/` / `app/` routes) or a known framework/config file (`next.config.*`, `middleware.*`, `tailwind.config.*`, etc.).
186
+ 2. **Transitively dead** β€” The file is only imported by files that are already dead; removing the safe-to-delete roots would leave it orphaned.
187
+ 3. **Dead exports** β€” The file is still used, but specific named exports are never imported by name elsewhere (namespace imports count as β€œall exports used”).
164
188
 
165
- 1. Next.js β€” all files inside `pages/` and `app/` directories
166
- 2. `package.json` `"main"` and `"module"` fields
167
- 3. Common fallbacks β€” `src/main`, `src/index`, `src/App`, `index` (all TS/JS extensions)
168
- 4. If none found β€” files with no importers are used as roots (safest heuristic)
189
+ **Why not β€œreachable from entry” only?** A pure reachability scan from entry points can mark heavily shared modules as dead if any link in the chain fails to resolve. The importer-based model matches the usual meaning of β€œnothing uses this file.”
169
190
 
170
- Any file not reachable from an entry point is flagged as dead. Circular dependencies are handled correctly β€” files in a cycle that are reachable from an entry point are always marked live.
191
+ **Circular imports** are reported separately in `circular.txt`; they are not lumped into β€œsafe to delete.”
171
192
 
172
193
  ---
173
194
 
@@ -218,7 +239,8 @@ src/
218
239
  β”œβ”€β”€ cli.ts # Entry point & CLI flag handling
219
240
  β”œβ”€β”€ core/
220
241
  β”‚ β”œβ”€β”€ parser.ts # File discovery + ts-morph AST parsing
221
- β”‚ β”œβ”€β”€ graph.ts # Import graph builder, DFS traversal, cycle detection
242
+ β”‚ β”œβ”€β”€ graph.ts # Import graph builder, cycle detection, reverse graph
243
+ β”‚ β”œβ”€β”€ report-parser.ts # Parse dead-code.txt / assets.md for --delete / --delete-assets
222
244
  β”‚ └── reporter.ts # Markdown / JSON / HTML output writer
223
245
  β”œβ”€β”€ modules/
224
246
  β”‚ β”œβ”€β”€ dead-code.ts # Dead file + dead export detection
package/dist/cli.cjs CHANGED
@@ -27,8 +27,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var import_commander = require("commander");
28
28
  var import_chalk7 = __toESM(require("chalk"), 1);
29
29
  var import_cli_table36 = __toESM(require("cli-table3"), 1);
30
- var import_node_fs9 = __toESM(require("fs"), 1);
31
- var import_node_path9 = __toESM(require("path"), 1);
30
+ var import_node_fs10 = __toESM(require("fs"), 1);
31
+ var import_node_path10 = __toESM(require("path"), 1);
32
32
  var import_node_url = require("url");
33
33
  var import_node_readline = __toESM(require("readline"), 1);
34
34
 
@@ -243,11 +243,11 @@ function detectCycles(graph) {
243
243
  const seenKeys = /* @__PURE__ */ new Set();
244
244
  const visited = /* @__PURE__ */ new Set();
245
245
  const inStack = /* @__PURE__ */ new Set();
246
- const path10 = [];
246
+ const path11 = [];
247
247
  const acc = { seenKeys, cycles };
248
248
  for (const start of graph.keys()) {
249
249
  if (!visited.has(start)) {
250
- dfsForCycles(start, graph, visited, inStack, path10, acc);
250
+ dfsForCycles(start, graph, visited, inStack, path11, acc);
251
251
  }
252
252
  }
253
253
  return cycles;
@@ -305,7 +305,7 @@ function resolveFallbackEntries(rootDir) {
305
305
  function mkFrame(node, graph) {
306
306
  return { node, neighbors: (graph.get(node) ?? /* @__PURE__ */ new Set()).values(), entered: false };
307
307
  }
308
- function dfsForCycles(start, graph, visited, inStack, path10, acc) {
308
+ function dfsForCycles(start, graph, visited, inStack, path11, acc) {
309
309
  const stack = [mkFrame(start, graph)];
310
310
  while (stack.length > 0) {
311
311
  const frame = stack.at(-1);
@@ -317,30 +317,30 @@ function dfsForCycles(start, graph, visited, inStack, path10, acc) {
317
317
  }
318
318
  frame.entered = true;
319
319
  inStack.add(frame.node);
320
- path10.push(frame.node);
320
+ path11.push(frame.node);
321
321
  }
322
322
  const { done, value: neighbor } = frame.neighbors.next();
323
323
  if (done) {
324
324
  stack.pop();
325
- path10.pop();
325
+ path11.pop();
326
326
  inStack.delete(frame.node);
327
327
  visited.add(frame.node);
328
328
  } else {
329
- handleCycleNeighbor(neighbor, stack, path10, inStack, visited, acc, graph);
329
+ handleCycleNeighbor(neighbor, stack, path11, inStack, visited, acc, graph);
330
330
  }
331
331
  }
332
332
  }
333
- function handleCycleNeighbor(neighbor, stack, path10, inStack, visited, acc, graph) {
333
+ function handleCycleNeighbor(neighbor, stack, path11, inStack, visited, acc, graph) {
334
334
  if (inStack.has(neighbor)) {
335
- recordCycle(neighbor, path10, acc);
335
+ recordCycle(neighbor, path11, acc);
336
336
  } else if (!visited.has(neighbor)) {
337
337
  stack.push(mkFrame(neighbor, graph));
338
338
  }
339
339
  }
340
- function recordCycle(cycleStart, path10, acc) {
341
- const idx = path10.indexOf(cycleStart);
340
+ function recordCycle(cycleStart, path11, acc) {
341
+ const idx = path11.indexOf(cycleStart);
342
342
  if (idx === -1) return;
343
- const cycle = normalizeCycle(path10.slice(idx));
343
+ const cycle = normalizeCycle(path11.slice(idx));
344
344
  const key = cycle.join("\0");
345
345
  if (!acc.seenKeys.has(key)) {
346
346
  acc.seenKeys.add(key);
@@ -1284,21 +1284,125 @@ function buildAssetReport(unused, totalAssets, rootDir) {
1284
1284
  return lines.join("\n");
1285
1285
  }
1286
1286
 
1287
+ // src/core/report-parser.ts
1288
+ var import_node_fs9 = __toESM(require("fs"), 1);
1289
+ var import_node_path9 = __toESM(require("path"), 1);
1290
+ function parseDeadCodeReportFile(reportPath, rootDir) {
1291
+ if (!import_node_fs9.default.existsSync(reportPath)) return [];
1292
+ const content = import_node_fs9.default.readFileSync(reportPath, "utf-8");
1293
+ return parseDeadCodeReportContent(content, rootDir);
1294
+ }
1295
+ function parseDeadCodeReportContent(content, rootDir) {
1296
+ const lines = content.split(/\r?\n/);
1297
+ const paths = [];
1298
+ const legacyRe = /^DEAD FILE β€”\s+(.+)$/;
1299
+ let legacyMode = content.includes("DEAD FILE \u2014") && content.includes("Dead files :");
1300
+ if (legacyMode) {
1301
+ for (const line of lines) {
1302
+ const m = legacyRe.exec(line.trim());
1303
+ if (m) paths.push(import_node_path9.default.resolve(rootDir, m[1].trim()));
1304
+ }
1305
+ return [...new Set(paths)];
1306
+ }
1307
+ let section = "none";
1308
+ const fileLineRe = /^\s{2}(.+?)\s+\(~[\d.]+\s+KB\)\s*$/;
1309
+ for (const line of lines) {
1310
+ if (line.includes("\u2500\u2500 SAFE TO DELETE \u2500\u2500")) {
1311
+ section = "safe";
1312
+ continue;
1313
+ }
1314
+ if (line.includes("\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500")) {
1315
+ section = "transitive";
1316
+ continue;
1317
+ }
1318
+ if (line.includes("\u2500\u2500 DEAD EXPORTS \u2500\u2500")) {
1319
+ section = "none";
1320
+ continue;
1321
+ }
1322
+ if (line.startsWith("\u2500\u2500 ") && line.includes("\u2500\u2500")) {
1323
+ if (section !== "none" && !line.includes("SAFE TO DELETE") && !line.includes("TRANSITIVELY DEAD")) {
1324
+ section = "none";
1325
+ }
1326
+ continue;
1327
+ }
1328
+ if (section === "safe" || section === "transitive") {
1329
+ if (line.includes("\u2514\u2500") || line.includes("also makes dead")) continue;
1330
+ const m = fileLineRe.exec(line);
1331
+ if (m) {
1332
+ const rel = m[1].trim();
1333
+ if (rel && !rel.startsWith("(")) {
1334
+ paths.push(import_node_path9.default.resolve(rootDir, rel));
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ return [...new Set(paths)];
1340
+ }
1341
+ function countDeadExportsInReport(content) {
1342
+ let section = false;
1343
+ let n = 0;
1344
+ for (const line of content.split(/\r?\n/)) {
1345
+ if (line.includes("\u2500\u2500 DEAD EXPORTS \u2500\u2500")) {
1346
+ section = true;
1347
+ continue;
1348
+ }
1349
+ if (section && line.startsWith("\u2500\u2500 ") && line.includes("\u2500\u2500")) break;
1350
+ if (section && /\s→\s.+\[line \d+\]/.test(line)) n++;
1351
+ }
1352
+ return n;
1353
+ }
1354
+ function parseAssetsReportFile(reportPath, rootDir) {
1355
+ if (!import_node_fs9.default.existsSync(reportPath)) return [];
1356
+ const content = import_node_fs9.default.readFileSync(reportPath, "utf-8");
1357
+ return parseAssetsReportContent(content, rootDir);
1358
+ }
1359
+ function parseAssetsReportContent(content, rootDir) {
1360
+ const lines = content.split(/\r?\n/);
1361
+ const paths = [];
1362
+ let inTable = false;
1363
+ let headerSeen = false;
1364
+ for (const line of lines) {
1365
+ const trimmed = line.trim();
1366
+ if (trimmed.startsWith("## Unused Assets")) {
1367
+ inTable = true;
1368
+ headerSeen = false;
1369
+ continue;
1370
+ }
1371
+ if (inTable && trimmed.startsWith("## ")) {
1372
+ break;
1373
+ }
1374
+ if (!inTable) continue;
1375
+ if (trimmed.startsWith("|") && trimmed.includes("---")) {
1376
+ headerSeen = true;
1377
+ continue;
1378
+ }
1379
+ if (!headerSeen || !trimmed.startsWith("|")) continue;
1380
+ const cells = trimmed.split("|").map((c) => c.trim()).filter(Boolean);
1381
+ if (cells.length >= 1) {
1382
+ const rel = cells[0];
1383
+ if (rel.toLowerCase() === "asset") continue;
1384
+ if (rel.includes("---")) continue;
1385
+ paths.push(import_node_path9.default.resolve(rootDir, rel));
1386
+ }
1387
+ }
1388
+ return [...new Set(paths)];
1389
+ }
1390
+
1287
1391
  // src/cli.ts
1288
1392
  var import_meta = {};
1289
1393
  function readPkgVersion() {
1290
1394
  try {
1291
1395
  if (typeof import_meta !== "undefined" && import_meta.url) {
1292
- const dir = import_node_path9.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1293
- const pkgPath = import_node_path9.default.resolve(dir, "..", "package.json");
1294
- return JSON.parse(import_node_fs9.default.readFileSync(pkgPath, "utf-8")).version;
1396
+ const dir = import_node_path10.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1397
+ const pkgPath = import_node_path10.default.resolve(dir, "..", "package.json");
1398
+ return JSON.parse(import_node_fs10.default.readFileSync(pkgPath, "utf-8")).version;
1295
1399
  }
1296
1400
  } catch {
1297
1401
  }
1298
1402
  try {
1299
1403
  const dir = globalThis.__dirname ?? __dirname;
1300
- const pkgPath = import_node_path9.default.resolve(dir, "..", "package.json");
1301
- return JSON.parse(import_node_fs9.default.readFileSync(pkgPath, "utf-8")).version;
1404
+ const pkgPath = import_node_path10.default.resolve(dir, "..", "package.json");
1405
+ return JSON.parse(import_node_fs10.default.readFileSync(pkgPath, "utf-8")).version;
1302
1406
  } catch {
1303
1407
  return "0.0.0";
1304
1408
  }
@@ -1311,11 +1415,14 @@ program.name("prunify").description("npm run clean. ship with confidence.").vers
1311
1415
  "Glob pattern to ignore (repeatable)",
1312
1416
  (val, acc) => [...acc, val],
1313
1417
  []
1314
- ).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);
1418
+ ).option("--out <path>", "Output directory for reports").option("--html", "Also generate code_health.html").option("--delete", "Delete dead files (uses prunify-reports/dead-code.txt if present, else runs analysis)").option(
1419
+ "--delete-assets",
1420
+ "Delete unused public assets (uses prunify-reports/assets.md if present, else runs asset scan)"
1421
+ ).option("--ci", "CI mode: exit 1 if issues found, no interactive prompts").action(main);
1315
1422
  program.parse();
1316
1423
  async function main(opts) {
1317
- const rootDir = import_node_path9.default.resolve(opts.dir);
1318
- if (!import_node_fs9.default.existsSync(import_node_path9.default.join(rootDir, "package.json"))) {
1424
+ const rootDir = import_node_path10.default.resolve(opts.dir);
1425
+ if (!import_node_fs10.default.existsSync(import_node_path10.default.join(rootDir, "package.json"))) {
1319
1426
  console.error(import_chalk7.default.red(`\u2717 No package.json found in ${rootDir}`));
1320
1427
  console.error(import_chalk7.default.dim(" Use --dir <path> to point to your project root."));
1321
1428
  process.exit(1);
@@ -1324,21 +1431,53 @@ async function main(opts) {
1324
1431
  console.log();
1325
1432
  console.log(import_chalk7.default.bold.cyan("\u{1F9F9} prunify \u2014 npm run clean. ship with confidence."));
1326
1433
  console.log();
1327
- const parseSpinner = createSpinner(import_chalk7.default.cyan("Parsing codebase\u2026"));
1328
- const files = discoverFiles(rootDir, opts.ignore);
1329
- parseSpinner.succeed(import_chalk7.default.green(`Parsed codebase \u2014 ${files.length} file(s) found`));
1330
- const graphSpinner = createSpinner(import_chalk7.default.cyan("Building import graph\u2026"));
1331
- const project = buildProject(files);
1332
- const graph = buildGraph(files, (f) => {
1333
- const sf = project.getSourceFile(f);
1334
- return sf ? getImportsForFile(sf) : [];
1335
- });
1336
- const edgeCount = [...graph.values()].reduce((n, s) => n + s.size, 0);
1337
- graphSpinner.succeed(import_chalk7.default.green(`Import graph built \u2014 ${edgeCount} edge(s)`));
1338
- const packageJson = loadPackageJson2(rootDir);
1339
- const entryPoints = opts.entry ? [import_node_path9.default.resolve(opts.entry)] : findEntryPoints(rootDir, packageJson);
1340
1434
  const reportsDir = ensureReportsDir(rootDir, opts.out);
1341
1435
  appendToGitignore(rootDir);
1436
+ const deadReportPath = import_node_path10.default.join(reportsDir, "dead-code.txt");
1437
+ const assetsReportPath = import_node_path10.default.join(reportsDir, "assets.md");
1438
+ const loadDeadFromCache = Boolean(opts.delete && import_node_fs10.default.existsSync(deadReportPath));
1439
+ const loadAssetsFromCache = Boolean(opts.deleteAssets && import_node_fs10.default.existsSync(assetsReportPath));
1440
+ let deadFilePaths = [];
1441
+ let unusedAssetPaths = [];
1442
+ if (loadDeadFromCache) {
1443
+ deadFilePaths = parseDeadCodeReportFile(deadReportPath, rootDir);
1444
+ console.log(
1445
+ import_chalk7.default.cyan(
1446
+ ` Using existing dead-code report (${deadFilePaths.length} file(s) to delete) \u2192 ${import_node_path10.default.relative(rootDir, deadReportPath)}`
1447
+ )
1448
+ );
1449
+ }
1450
+ if (loadAssetsFromCache) {
1451
+ unusedAssetPaths = parseAssetsReportFile(assetsReportPath, rootDir);
1452
+ console.log(
1453
+ import_chalk7.default.cyan(
1454
+ ` Using existing assets report (${unusedAssetPaths.length} asset(s) to delete) \u2192 ${import_node_path10.default.relative(rootDir, assetsReportPath)}`
1455
+ )
1456
+ );
1457
+ }
1458
+ const runDeadCode2 = !loadDeadFromCache && (modules.includes("dead-code") || Boolean(opts.delete));
1459
+ const runCircular2 = modules.includes("circular");
1460
+ const runAssets = !loadAssetsFromCache && (modules.includes("assets") || Boolean(opts.deleteAssets));
1461
+ const needsGraph = runCircular2 || runDeadCode2;
1462
+ let files = [];
1463
+ let project;
1464
+ let graph;
1465
+ let entryPoints = [];
1466
+ if (needsGraph) {
1467
+ const parseSpinner = createSpinner(import_chalk7.default.cyan("Parsing codebase\u2026"));
1468
+ files = discoverFiles(rootDir, opts.ignore);
1469
+ parseSpinner.succeed(import_chalk7.default.green(`Parsed codebase \u2014 ${files.length} file(s) found`));
1470
+ const graphSpinner = createSpinner(import_chalk7.default.cyan("Building import graph\u2026"));
1471
+ project = buildProject(files);
1472
+ graph = buildGraph(files, (f) => {
1473
+ const sf = project.getSourceFile(f);
1474
+ return sf ? getImportsForFile(sf) : [];
1475
+ });
1476
+ const edgeCount = [...graph.values()].reduce((n, s) => n + s.size, 0);
1477
+ graphSpinner.succeed(import_chalk7.default.green(`Import graph built \u2014 ${edgeCount} edge(s)`));
1478
+ const packageJson = loadPackageJson2(rootDir);
1479
+ entryPoints = opts.entry ? [import_node_path10.default.resolve(opts.entry)] : findEntryPoints(rootDir, packageJson);
1480
+ }
1342
1481
  console.log();
1343
1482
  let deadFileCount = 0;
1344
1483
  let dupeCount = 0;
@@ -1350,12 +1489,24 @@ async function main(opts) {
1350
1489
  let depsReportFile = "";
1351
1490
  let circularReportFile = "";
1352
1491
  let assetsReportFile = "";
1353
- const deadFilePaths = [];
1354
- if (modules.includes("dead-code")) {
1492
+ if (loadDeadFromCache) {
1493
+ deadReportFile = "dead-code.txt";
1494
+ try {
1495
+ const raw = import_node_fs10.default.readFileSync(deadReportPath, "utf-8");
1496
+ deadFileCount = deadFilePaths.length + countDeadExportsInReport(raw);
1497
+ } catch {
1498
+ deadFileCount = deadFilePaths.length;
1499
+ }
1500
+ }
1501
+ if (loadAssetsFromCache) {
1502
+ assetsReportFile = "assets.md";
1503
+ unusedAssetCount = unusedAssetPaths.length;
1504
+ }
1505
+ if (runDeadCode2) {
1355
1506
  const spinner = createSpinner(import_chalk7.default.cyan("Analysing dead code\u2026"));
1356
1507
  const result = runDeadCodeModule(project, graph, entryPoints, rootDir);
1357
1508
  deadFileCount = result.safeToDelete.length + result.transitivelyDead.length + result.deadExports.length;
1358
- deadFilePaths.push(...result.safeToDelete, ...result.transitivelyDead);
1509
+ deadFilePaths = [...result.safeToDelete, ...result.transitivelyDead];
1359
1510
  spinner.succeed(
1360
1511
  import_chalk7.default.green(
1361
1512
  `Dead code analysis complete \u2014 ${result.safeToDelete.length} safe to delete, ${result.transitivelyDead.length} transitively dead, ${result.deadExports.length} dead export(s)`
@@ -1370,19 +1521,19 @@ async function main(opts) {
1370
1521
  }
1371
1522
  }
1372
1523
  if (modules.includes("dupes")) {
1373
- const outputPath = import_node_path9.default.join(reportsDir, "dupes.md");
1524
+ const outputPath = import_node_path10.default.join(reportsDir, "dupes.md");
1374
1525
  const dupes = await runDupeFinder(rootDir, { output: outputPath });
1375
1526
  dupeCount = dupes.length;
1376
1527
  if (dupeCount > 0) dupesReportFile = "dupes.md";
1377
1528
  }
1378
- if (modules.includes("circular")) {
1529
+ if (runCircular2) {
1379
1530
  const spinner = createSpinner(import_chalk7.default.cyan("Analysing circular imports\u2026"));
1380
1531
  const cycles = detectCycles(graph);
1381
1532
  circularCount = cycles.length;
1382
1533
  spinner.succeed(import_chalk7.default.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
1383
1534
  if (circularCount > 0) {
1384
1535
  circularReportFile = "circular.txt";
1385
- const rel = (p) => import_node_path9.default.relative(rootDir, p).replaceAll("\\", "/");
1536
+ const rel = (p) => import_node_path10.default.relative(rootDir, p).replaceAll("\\", "/");
1386
1537
  const cycleLines = [
1387
1538
  "========================================",
1388
1539
  " CIRCULAR DEPENDENCIES",
@@ -1407,23 +1558,24 @@ async function main(opts) {
1407
1558
  }
1408
1559
  }
1409
1560
  if (modules.includes("deps")) {
1410
- const outputPath = import_node_path9.default.join(reportsDir, "deps.md");
1561
+ const outputPath = import_node_path10.default.join(reportsDir, "deps.md");
1411
1562
  const issues = await runDepCheck({ cwd: rootDir, output: outputPath });
1412
1563
  unusedPkgCount = issues.filter((i) => i.type === "unused").length;
1413
1564
  if (issues.length > 0) depsReportFile = "deps.md";
1414
1565
  }
1415
- if (modules.includes("assets")) {
1416
- const outputPath = import_node_path9.default.join(reportsDir, "assets.md");
1566
+ if (runAssets) {
1567
+ const outputPath = import_node_path10.default.join(reportsDir, "assets.md");
1417
1568
  const unusedAssets = await runAssetCheck(rootDir, { output: outputPath });
1569
+ unusedAssetPaths = unusedAssets.map((a) => a.filePath);
1418
1570
  unusedAssetCount = unusedAssets.length;
1419
1571
  if (unusedAssetCount > 0) assetsReportFile = "assets.md";
1420
1572
  }
1421
1573
  if (modules.includes("health")) {
1422
- const outputPath = import_node_path9.default.join(reportsDir, "health-report.md");
1574
+ const outputPath = import_node_path10.default.join(reportsDir, "health-report.md");
1423
1575
  await runHealthReport(rootDir, { output: outputPath });
1424
1576
  }
1425
1577
  if (opts.html) {
1426
- const htmlPath = import_node_path9.default.join(reportsDir, "code_health.html");
1578
+ const htmlPath = import_node_path10.default.join(reportsDir, "code_health.html");
1427
1579
  writeHtmlReport(htmlPath, rootDir, deadFilePaths, circularCount, dupeCount, unusedPkgCount);
1428
1580
  console.log(import_chalk7.default.cyan(` HTML report written to ${htmlPath}`));
1429
1581
  }
@@ -1445,22 +1597,72 @@ async function main(opts) {
1445
1597
  console.log(table.toString());
1446
1598
  console.log();
1447
1599
  if (opts.delete && deadFilePaths.length > 0) {
1448
- console.log(import_chalk7.default.yellow(`Dead files (${deadFilePaths.length}):`));
1600
+ console.log(import_chalk7.default.yellow(`Dead code files to delete (${deadFilePaths.length}):`));
1449
1601
  for (const f of deadFilePaths) {
1450
- console.log(import_chalk7.default.dim(` ${import_node_path9.default.relative(rootDir, f)}`));
1602
+ console.log(import_chalk7.default.dim(` ${import_node_path10.default.relative(rootDir, f)}`));
1451
1603
  }
1452
1604
  console.log();
1453
1605
  if (!opts.ci) {
1454
- const confirmed = await confirmPrompt("Delete these files? (y/N) ");
1606
+ const confirmed = await confirmPrompt("Delete these dead code files? (y/N) ");
1455
1607
  if (confirmed) {
1608
+ let removed = 0;
1456
1609
  for (const f of deadFilePaths) {
1457
- import_node_fs9.default.rmSync(f, { force: true });
1610
+ try {
1611
+ if (import_node_fs10.default.existsSync(f)) {
1612
+ import_node_fs10.default.rmSync(f, { force: true });
1613
+ removed++;
1614
+ }
1615
+ } catch {
1616
+ console.log(import_chalk7.default.red(` Failed to delete: ${import_node_path10.default.relative(rootDir, f)}`));
1617
+ }
1618
+ }
1619
+ console.log(import_chalk7.default.green(` Deleted ${removed} file(s).`));
1620
+ } else {
1621
+ console.log(import_chalk7.default.dim(" Skipped."));
1622
+ }
1623
+ }
1624
+ } else if (opts.delete && deadFilePaths.length === 0) {
1625
+ console.log(import_chalk7.default.dim(" --delete: no dead code files listed in report (nothing to delete)."));
1626
+ }
1627
+ if (opts.deleteAssets && unusedAssetPaths.length > 0) {
1628
+ const publicDir = import_node_path10.default.join(rootDir, "public");
1629
+ const safePaths = unusedAssetPaths.filter((p) => {
1630
+ const norm = import_node_path10.default.normalize(p);
1631
+ return norm.startsWith(import_node_path10.default.normalize(publicDir + import_node_path10.default.sep));
1632
+ });
1633
+ if (safePaths.length !== unusedAssetPaths.length) {
1634
+ console.log(
1635
+ import_chalk7.default.yellow(
1636
+ ` Warning: skipped ${unusedAssetPaths.length - safePaths.length} path(s) outside public/`
1637
+ )
1638
+ );
1639
+ }
1640
+ console.log(import_chalk7.default.yellow(`Unused assets to delete (${safePaths.length}):`));
1641
+ for (const f of safePaths) {
1642
+ console.log(import_chalk7.default.dim(` ${import_node_path10.default.relative(rootDir, f)}`));
1643
+ }
1644
+ console.log();
1645
+ if (!opts.ci) {
1646
+ const confirmed = await confirmPrompt("Delete these unused assets? (y/N) ");
1647
+ if (confirmed) {
1648
+ let removed = 0;
1649
+ for (const f of safePaths) {
1650
+ try {
1651
+ if (import_node_fs10.default.existsSync(f)) {
1652
+ import_node_fs10.default.rmSync(f, { force: true });
1653
+ removed++;
1654
+ }
1655
+ } catch {
1656
+ console.log(import_chalk7.default.red(` Failed to delete: ${import_node_path10.default.relative(rootDir, f)}`));
1657
+ }
1458
1658
  }
1459
- console.log(import_chalk7.default.green(` Deleted ${deadFilePaths.length} file(s).`));
1659
+ console.log(import_chalk7.default.green(` Deleted ${removed} asset file(s).`));
1460
1660
  } else {
1461
1661
  console.log(import_chalk7.default.dim(" Skipped."));
1462
1662
  }
1463
1663
  }
1664
+ } else if (opts.deleteAssets && unusedAssetPaths.length === 0) {
1665
+ console.log(import_chalk7.default.dim(" --delete-assets: no unused assets in report (nothing to delete)."));
1464
1666
  }
1465
1667
  if (opts.ci) {
1466
1668
  const hasIssues = deadFileCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0 || unusedAssetCount > 0;
@@ -1474,7 +1676,7 @@ function resolveModules(only) {
1474
1676
  }
1475
1677
  function loadPackageJson2(dir) {
1476
1678
  try {
1477
- return JSON.parse(import_node_fs9.default.readFileSync(import_node_path9.default.join(dir, "package.json"), "utf-8"));
1679
+ return JSON.parse(import_node_fs10.default.readFileSync(import_node_path10.default.join(dir, "package.json"), "utf-8"));
1478
1680
  } catch {
1479
1681
  return null;
1480
1682
  }
@@ -1495,7 +1697,7 @@ function writeHtmlReport(outputPath, rootDir, deadFiles, circularCount, dupeCoun
1495
1697
  ["Circular Dependencies", String(circularCount)],
1496
1698
  ["Unused Packages", String(unusedPkgCount)]
1497
1699
  ].map(([label, val]) => ` <tr><td>${label}</td><td>${val}</td></tr>`).join("\n");
1498
- const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${import_node_path9.default.relative(rootDir, f)}</li>`).join("")}</ul>` : "<p>None</p>";
1700
+ const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${import_node_path10.default.relative(rootDir, f)}</li>`).join("")}</ul>` : "<p>None</p>";
1499
1701
  const html = `<!DOCTYPE html>
1500
1702
  <html lang="en">
1501
1703
  <head>
@@ -1522,7 +1724,7 @@ ${rows}
1522
1724
  ${deadList}
1523
1725
  </body>
1524
1726
  </html>`;
1525
- import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(outputPath), { recursive: true });
1526
- import_node_fs9.default.writeFileSync(outputPath, html, "utf-8");
1727
+ import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(outputPath), { recursive: true });
1728
+ import_node_fs10.default.writeFileSync(outputPath, html, "utf-8");
1527
1729
  }
1528
1730
  //# sourceMappingURL=cli.cjs.map