prunify 0.1.4 β 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 +47 -25
- package/dist/cli.cjs +264 -56
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +264 -56
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
| Module | What it finds |
|
|
30
30
|
|--------|---------------|
|
|
31
|
-
| ποΈ **Dead code** |
|
|
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 β
|
|
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
|
|
94
|
-
|
|
95
|
-
β Dead
|
|
96
|
-
β
|
|
97
|
-
β
|
|
98
|
-
β
|
|
99
|
-
β Unused Assets
|
|
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` |
|
|
116
|
-
| `--
|
|
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
|
-
#
|
|
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` |
|
|
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
|
|
183
|
+
prunify builds a directed import graph (with `tsconfig.json` path aliases) and a **reverse** map of βwho imports this fileβ.
|
|
162
184
|
|
|
163
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
31
|
-
var
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
path11.pop();
|
|
326
326
|
inStack.delete(frame.node);
|
|
327
327
|
visited.add(frame.node);
|
|
328
328
|
} else {
|
|
329
|
-
handleCycleNeighbor(neighbor, stack,
|
|
329
|
+
handleCycleNeighbor(neighbor, stack, path11, inStack, visited, acc, graph);
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
|
-
function handleCycleNeighbor(neighbor, stack,
|
|
333
|
+
function handleCycleNeighbor(neighbor, stack, path11, inStack, visited, acc, graph) {
|
|
334
334
|
if (inStack.has(neighbor)) {
|
|
335
|
-
recordCycle(neighbor,
|
|
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,
|
|
341
|
-
const idx =
|
|
340
|
+
function recordCycle(cycleStart, path11, acc) {
|
|
341
|
+
const idx = path11.indexOf(cycleStart);
|
|
342
342
|
if (idx === -1) return;
|
|
343
|
-
const cycle = normalizeCycle(
|
|
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 =
|
|
1293
|
-
const pkgPath =
|
|
1294
|
-
return JSON.parse(
|
|
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 =
|
|
1301
|
-
return JSON.parse(
|
|
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", "
|
|
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 =
|
|
1318
|
-
if (!
|
|
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
|
-
|
|
1354
|
-
|
|
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
|
|
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)`
|
|
@@ -1363,23 +1514,26 @@ async function main(opts) {
|
|
|
1363
1514
|
);
|
|
1364
1515
|
if (result.report) {
|
|
1365
1516
|
deadReportFile = "dead-code.txt";
|
|
1366
|
-
|
|
1517
|
+
const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1518
|
+
|
|
1519
|
+
`;
|
|
1520
|
+
writeReport(reportsDir, deadReportFile, banner + result.report);
|
|
1367
1521
|
}
|
|
1368
1522
|
}
|
|
1369
1523
|
if (modules.includes("dupes")) {
|
|
1370
|
-
const outputPath =
|
|
1524
|
+
const outputPath = import_node_path10.default.join(reportsDir, "dupes.md");
|
|
1371
1525
|
const dupes = await runDupeFinder(rootDir, { output: outputPath });
|
|
1372
1526
|
dupeCount = dupes.length;
|
|
1373
1527
|
if (dupeCount > 0) dupesReportFile = "dupes.md";
|
|
1374
1528
|
}
|
|
1375
|
-
if (
|
|
1529
|
+
if (runCircular2) {
|
|
1376
1530
|
const spinner = createSpinner(import_chalk7.default.cyan("Analysing circular imports\u2026"));
|
|
1377
1531
|
const cycles = detectCycles(graph);
|
|
1378
1532
|
circularCount = cycles.length;
|
|
1379
1533
|
spinner.succeed(import_chalk7.default.green(`Circular import analysis complete \u2014 ${circularCount} cycle(s) found`));
|
|
1380
1534
|
if (circularCount > 0) {
|
|
1381
1535
|
circularReportFile = "circular.txt";
|
|
1382
|
-
const rel = (p) =>
|
|
1536
|
+
const rel = (p) => import_node_path10.default.relative(rootDir, p).replaceAll("\\", "/");
|
|
1383
1537
|
const cycleLines = [
|
|
1384
1538
|
"========================================",
|
|
1385
1539
|
" CIRCULAR DEPENDENCIES",
|
|
@@ -1397,27 +1551,31 @@ async function main(opts) {
|
|
|
1397
1551
|
cycleLines.push(` \u21BB ${rel(cycle[0])} (back to start)`);
|
|
1398
1552
|
cycleLines.push("");
|
|
1399
1553
|
}
|
|
1400
|
-
|
|
1554
|
+
const banner = `prunify ${PKG_VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1555
|
+
|
|
1556
|
+
`;
|
|
1557
|
+
writeReport(reportsDir, circularReportFile, banner + cycleLines.join("\n"));
|
|
1401
1558
|
}
|
|
1402
1559
|
}
|
|
1403
1560
|
if (modules.includes("deps")) {
|
|
1404
|
-
const outputPath =
|
|
1561
|
+
const outputPath = import_node_path10.default.join(reportsDir, "deps.md");
|
|
1405
1562
|
const issues = await runDepCheck({ cwd: rootDir, output: outputPath });
|
|
1406
1563
|
unusedPkgCount = issues.filter((i) => i.type === "unused").length;
|
|
1407
1564
|
if (issues.length > 0) depsReportFile = "deps.md";
|
|
1408
1565
|
}
|
|
1409
|
-
if (
|
|
1410
|
-
const outputPath =
|
|
1566
|
+
if (runAssets) {
|
|
1567
|
+
const outputPath = import_node_path10.default.join(reportsDir, "assets.md");
|
|
1411
1568
|
const unusedAssets = await runAssetCheck(rootDir, { output: outputPath });
|
|
1569
|
+
unusedAssetPaths = unusedAssets.map((a) => a.filePath);
|
|
1412
1570
|
unusedAssetCount = unusedAssets.length;
|
|
1413
1571
|
if (unusedAssetCount > 0) assetsReportFile = "assets.md";
|
|
1414
1572
|
}
|
|
1415
1573
|
if (modules.includes("health")) {
|
|
1416
|
-
const outputPath =
|
|
1574
|
+
const outputPath = import_node_path10.default.join(reportsDir, "health-report.md");
|
|
1417
1575
|
await runHealthReport(rootDir, { output: outputPath });
|
|
1418
1576
|
}
|
|
1419
1577
|
if (opts.html) {
|
|
1420
|
-
const htmlPath =
|
|
1578
|
+
const htmlPath = import_node_path10.default.join(reportsDir, "code_health.html");
|
|
1421
1579
|
writeHtmlReport(htmlPath, rootDir, deadFilePaths, circularCount, dupeCount, unusedPkgCount);
|
|
1422
1580
|
console.log(import_chalk7.default.cyan(` HTML report written to ${htmlPath}`));
|
|
1423
1581
|
}
|
|
@@ -1439,22 +1597,72 @@ async function main(opts) {
|
|
|
1439
1597
|
console.log(table.toString());
|
|
1440
1598
|
console.log();
|
|
1441
1599
|
if (opts.delete && deadFilePaths.length > 0) {
|
|
1442
|
-
console.log(import_chalk7.default.yellow(`Dead files (${deadFilePaths.length}):`));
|
|
1600
|
+
console.log(import_chalk7.default.yellow(`Dead code files to delete (${deadFilePaths.length}):`));
|
|
1443
1601
|
for (const f of deadFilePaths) {
|
|
1444
|
-
console.log(import_chalk7.default.dim(` ${
|
|
1602
|
+
console.log(import_chalk7.default.dim(` ${import_node_path10.default.relative(rootDir, f)}`));
|
|
1445
1603
|
}
|
|
1446
1604
|
console.log();
|
|
1447
1605
|
if (!opts.ci) {
|
|
1448
|
-
const confirmed = await confirmPrompt("Delete these files? (y/N) ");
|
|
1606
|
+
const confirmed = await confirmPrompt("Delete these dead code files? (y/N) ");
|
|
1449
1607
|
if (confirmed) {
|
|
1608
|
+
let removed = 0;
|
|
1450
1609
|
for (const f of deadFilePaths) {
|
|
1451
|
-
|
|
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
|
+
}
|
|
1452
1658
|
}
|
|
1453
|
-
console.log(import_chalk7.default.green(` Deleted ${
|
|
1659
|
+
console.log(import_chalk7.default.green(` Deleted ${removed} asset file(s).`));
|
|
1454
1660
|
} else {
|
|
1455
1661
|
console.log(import_chalk7.default.dim(" Skipped."));
|
|
1456
1662
|
}
|
|
1457
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)."));
|
|
1458
1666
|
}
|
|
1459
1667
|
if (opts.ci) {
|
|
1460
1668
|
const hasIssues = deadFileCount > 0 || dupeCount > 0 || unusedPkgCount > 0 || circularCount > 0 || unusedAssetCount > 0;
|
|
@@ -1468,7 +1676,7 @@ function resolveModules(only) {
|
|
|
1468
1676
|
}
|
|
1469
1677
|
function loadPackageJson2(dir) {
|
|
1470
1678
|
try {
|
|
1471
|
-
return JSON.parse(
|
|
1679
|
+
return JSON.parse(import_node_fs10.default.readFileSync(import_node_path10.default.join(dir, "package.json"), "utf-8"));
|
|
1472
1680
|
} catch {
|
|
1473
1681
|
return null;
|
|
1474
1682
|
}
|
|
@@ -1489,7 +1697,7 @@ function writeHtmlReport(outputPath, rootDir, deadFiles, circularCount, dupeCoun
|
|
|
1489
1697
|
["Circular Dependencies", String(circularCount)],
|
|
1490
1698
|
["Unused Packages", String(unusedPkgCount)]
|
|
1491
1699
|
].map(([label, val]) => ` <tr><td>${label}</td><td>${val}</td></tr>`).join("\n");
|
|
1492
|
-
const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${
|
|
1700
|
+
const deadList = deadFiles.length > 0 ? `<ul>${deadFiles.map((f) => `<li>${import_node_path10.default.relative(rootDir, f)}</li>`).join("")}</ul>` : "<p>None</p>";
|
|
1493
1701
|
const html = `<!DOCTYPE html>
|
|
1494
1702
|
<html lang="en">
|
|
1495
1703
|
<head>
|
|
@@ -1516,7 +1724,7 @@ ${rows}
|
|
|
1516
1724
|
${deadList}
|
|
1517
1725
|
</body>
|
|
1518
1726
|
</html>`;
|
|
1519
|
-
|
|
1520
|
-
|
|
1727
|
+
import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(outputPath), { recursive: true });
|
|
1728
|
+
import_node_fs10.default.writeFileSync(outputPath, html, "utf-8");
|
|
1521
1729
|
}
|
|
1522
1730
|
//# sourceMappingURL=cli.cjs.map
|