pruny 1.36.1 → 1.38.0
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 +132 -44
- package/dist/index.js +286 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# pruny
|
|
2
2
|
|
|
3
|
-
Find and remove unused Next.js
|
|
3
|
+
Find and remove unused code in Next.js and NestJS projects.
|
|
4
|
+
|
|
5
|
+
Pruny scans your codebase using regex-based static analysis to detect unused API routes, page links, exports, files, public assets, and NestJS service methods. Works with monorepos out of the box.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -10,67 +12,153 @@ npm install -g pruny
|
|
|
10
12
|
npx pruny
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
##
|
|
15
|
+
## What It Detects
|
|
16
|
+
|
|
17
|
+
| Scanner | What it finds |
|
|
18
|
+
| :------ | :------------ |
|
|
19
|
+
| **API Routes** | Unused Next.js `route.ts` handlers and NestJS controller methods |
|
|
20
|
+
| **Broken Links** | `<Link>`, `router.push()`, `redirect()` pointing to pages that don't exist |
|
|
21
|
+
| **Unused Exports** | Named exports and class methods not imported anywhere |
|
|
22
|
+
| **Unused Files** | Source files not reachable from any entry point |
|
|
23
|
+
| **Unused Services** | NestJS service methods never called by controllers or other services |
|
|
24
|
+
| **Public Assets** | Images/files in `public/` not referenced in code |
|
|
25
|
+
| **Source Assets** | Media files in `src/` not referenced in code |
|
|
26
|
+
| **Missing Assets** | References to files in `public/` that don't exist |
|
|
14
27
|
|
|
15
28
|
## CLI Commands
|
|
16
29
|
|
|
17
|
-
| Command
|
|
18
|
-
|
|
|
19
|
-
| `pruny`
|
|
20
|
-
| `pruny --
|
|
21
|
-
| `pruny --
|
|
22
|
-
| `pruny --
|
|
23
|
-
| `pruny --
|
|
24
|
-
| `pruny --
|
|
25
|
-
| `pruny --
|
|
26
|
-
| `pruny --
|
|
27
|
-
| `pruny --
|
|
28
|
-
| `pruny --
|
|
29
|
-
| `pruny
|
|
30
|
-
| `pruny
|
|
30
|
+
| Command | Description |
|
|
31
|
+
| :------ | :---------- |
|
|
32
|
+
| `pruny` | Interactive scan (auto-detects monorepo apps) |
|
|
33
|
+
| `pruny --all` | CI mode: scan all apps, exit 1 if issues found |
|
|
34
|
+
| `pruny --fix` | Interactively delete unused items |
|
|
35
|
+
| `pruny --dry-run` | Simulate fix mode and output a JSON report |
|
|
36
|
+
| `pruny --app <name>` | Scan a specific app in a monorepo |
|
|
37
|
+
| `pruny --folder <path>` | Scan a specific folder for routes/controllers |
|
|
38
|
+
| `pruny --cleanup <items>` | Quick cleanup: `routes`, `exports`, `public`, `files` |
|
|
39
|
+
| `pruny --filter <pattern>` | Filter results by path or app name |
|
|
40
|
+
| `pruny --ignore-apps <list>` | Skip specific apps (comma-separated) |
|
|
41
|
+
| `pruny --no-public` | Skip public asset scanning |
|
|
42
|
+
| `pruny --json` | Output results as JSON |
|
|
43
|
+
| `pruny -v, --verbose` | Verbose debug logging |
|
|
44
|
+
| `pruny --dir <path>` | Set target directory (default: `./`) |
|
|
45
|
+
| `pruny -c, --config <path>` | Path to config file |
|
|
46
|
+
| `pruny init` | Generate a `pruny.config.json` with defaults |
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Create `pruny.config.json` in your project root (or run `pruny init`):
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"ignore": {
|
|
55
|
+
"routes": ["/api/webhooks/**", "/api/cron/**"],
|
|
56
|
+
"folders": ["node_modules", ".next", "dist"],
|
|
57
|
+
"files": ["*.test.ts", "*.spec.ts"],
|
|
58
|
+
"links": ["/custom-path", "/legacy/*"]
|
|
59
|
+
},
|
|
60
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx"]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Ignore Options
|
|
65
|
+
|
|
66
|
+
| Key | What it does | Example |
|
|
67
|
+
| :-- | :----------- | :------ |
|
|
68
|
+
| `ignore.routes` | Skip API routes matching these patterns | `["/api/webhooks/**"]` |
|
|
69
|
+
| `ignore.folders` | Exclude directories from scanning | `["node_modules", "dist"]` |
|
|
70
|
+
| `ignore.files` | Exclude specific files from scanning | `["*.test.ts"]` |
|
|
71
|
+
| `ignore.links` | Suppress broken link warnings for these paths | `["/view_seat", "/admin/*"]` |
|
|
72
|
+
|
|
73
|
+
All patterns support glob syntax (`*` matches any characters, `**` matches nested paths).
|
|
74
|
+
|
|
75
|
+
Pruny also reads `.gitignore` and automatically excludes those folders.
|
|
76
|
+
|
|
77
|
+
### Additional Config Options
|
|
78
|
+
|
|
79
|
+
| Key | What it does |
|
|
80
|
+
| :-- | :----------- |
|
|
81
|
+
| `nestGlobalPrefix` | NestJS global route prefix (e.g., `"api/v1"`) |
|
|
82
|
+
| `extraRoutePatterns` | Additional glob patterns to detect route files |
|
|
83
|
+
| `excludePublic` | Set `true` to skip public asset scanning |
|
|
31
84
|
|
|
32
|
-
###
|
|
85
|
+
### Config File Locations
|
|
33
86
|
|
|
34
|
-
Pruny
|
|
87
|
+
Pruny searches for config files recursively across your project:
|
|
88
|
+
- `pruny.config.json`
|
|
89
|
+
- `.prunyrc.json`
|
|
90
|
+
- `.prunyrc`
|
|
35
91
|
|
|
36
|
-
|
|
37
|
-
- **Disable it**: Use `--no-public` flag.
|
|
38
|
-
```bash
|
|
39
|
-
pruny --no-public
|
|
40
|
-
```
|
|
41
|
-
- **How it works**: It checks if filenames in `public/` (e.g., `logo.png` or `/images/logo.png`) are referenced in your code.
|
|
92
|
+
In monorepos, configs from multiple apps are merged together. CLI `--config` takes precedence.
|
|
42
93
|
|
|
43
|
-
##
|
|
94
|
+
## Multi-Tenant / Subdomain Routing
|
|
44
95
|
|
|
45
|
-
|
|
96
|
+
Pruny automatically handles multi-tenant architectures where routes live under dynamic segments like `[domain]`.
|
|
97
|
+
|
|
98
|
+
For example, if your file structure is:
|
|
99
|
+
```
|
|
100
|
+
app/(code)/tenant_sites/[domain]/view_seat/page.tsx
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
And your components reference `/view_seat` (resolved at runtime via subdomain), Pruny recognizes this as a valid route and will **not** report it as a broken link.
|
|
104
|
+
|
|
105
|
+
If auto-detection doesn't cover your case, use `ignore.links` in config:
|
|
46
106
|
|
|
47
107
|
```json
|
|
48
108
|
{
|
|
49
|
-
"dir": "./",
|
|
50
109
|
"ignore": {
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
"files": ["*.test.ts", "*.spec.ts"]
|
|
54
|
-
},
|
|
55
|
-
"extensions": [".ts", ".tsx", ".js", ".jsx"]
|
|
110
|
+
"links": ["/view_seat", "/review", "/custom-path"]
|
|
111
|
+
}
|
|
56
112
|
}
|
|
57
113
|
```
|
|
58
114
|
|
|
59
|
-
##
|
|
115
|
+
## Monorepo Support
|
|
116
|
+
|
|
117
|
+
Pruny auto-detects monorepos by looking for an `apps/` directory. It scans each app independently but checks references across the entire monorepo root.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Scan all apps (CI-friendly, exits 1 on issues)
|
|
121
|
+
pruny --all
|
|
122
|
+
|
|
123
|
+
# Scan a specific app
|
|
124
|
+
pruny --app web
|
|
125
|
+
|
|
126
|
+
# Skip certain apps
|
|
127
|
+
pruny --ignore-apps admin,docs
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## CI Integration
|
|
60
131
|
|
|
61
|
-
|
|
62
|
-
- 🗑️ `--fix` flag to delete unused routes
|
|
63
|
-
- ⚡ Auto-detects `vercel.json` cron routes
|
|
64
|
-
- 📁 Default ignores: `node_modules`, `.next`, `dist`, `.git`
|
|
65
|
-
- 🎨 Beautiful CLI output
|
|
132
|
+
Add to your CI pipeline to catch unused code before merging:
|
|
66
133
|
|
|
67
|
-
|
|
134
|
+
```bash
|
|
135
|
+
npx pruny --all
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This scans all monorepo apps and exits with code 1 if any issues are found. Combine with `--json` for machine-readable output.
|
|
139
|
+
|
|
140
|
+
## How It Works
|
|
141
|
+
|
|
142
|
+
1. **Route Detection**: Finds all `app/api/**/route.ts` (Next.js) and `*.controller.ts` (NestJS) files
|
|
143
|
+
2. **Link Detection**: Finds `<Link>`, `router.push()`, `redirect()`, `href:` patterns and validates against known page routes
|
|
144
|
+
3. **Reference Scanning**: Searches the entire codebase for string references to routes, exports, and assets
|
|
145
|
+
4. **Dynamic Route Matching**: Understands `[id]`, `[...slug]`, `[[...slug]]` dynamic segments
|
|
146
|
+
5. **Fix Mode**: Removes unused methods, exports, and files with a cascading second pass to catch newly dead code
|
|
147
|
+
|
|
148
|
+
### Vercel Cron Detection
|
|
149
|
+
|
|
150
|
+
Routes listed in `vercel.json` cron jobs are automatically marked as used:
|
|
151
|
+
```json
|
|
152
|
+
{ "crons": [{ "path": "/api/cron/cleanup", "schedule": "0 0 * * *" }] }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Debug Mode
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
DEBUG_PRUNY=1 pruny
|
|
159
|
+
```
|
|
68
160
|
|
|
69
|
-
|
|
70
|
-
2. **Nest.js**: Finds all `*.controller.ts` files and extracts mapped routes (e.g., `@Get('users')`).
|
|
71
|
-
3. Scans codebase for client-side usages (e.g., `fetch`, `axios`, or string literals matching the route).
|
|
72
|
-
4. Reports routes with no detected references.
|
|
73
|
-
5. `--fix` deletes the unused route file or method.
|
|
161
|
+
Enables verbose logging across all scanners.
|
|
74
162
|
|
|
75
163
|
## License
|
|
76
164
|
|
package/dist/index.js
CHANGED
|
@@ -12567,8 +12567,8 @@ import { rmSync, existsSync as existsSync9, readdirSync, lstatSync, writeFileSyn
|
|
|
12567
12567
|
import { dirname as dirname5, join as join10, relative as relative5, resolve as resolve3 } from "node:path";
|
|
12568
12568
|
|
|
12569
12569
|
// src/scanner.ts
|
|
12570
|
-
var
|
|
12571
|
-
import { existsSync as existsSync6, readFileSync as
|
|
12570
|
+
var import_fast_glob10 = __toESM(require_out4(), 1);
|
|
12571
|
+
import { existsSync as existsSync6, readFileSync as readFileSync9 } from "node:fs";
|
|
12572
12572
|
import { join as join7 } from "node:path";
|
|
12573
12573
|
|
|
12574
12574
|
// src/patterns.ts
|
|
@@ -15729,6 +15729,192 @@ async function scanUnusedServices(config) {
|
|
|
15729
15729
|
return { total: methods.length, methods };
|
|
15730
15730
|
}
|
|
15731
15731
|
|
|
15732
|
+
// src/scanners/broken-links.ts
|
|
15733
|
+
var import_fast_glob9 = __toESM(require_out4(), 1);
|
|
15734
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
15735
|
+
var LINK_PATTERNS = [
|
|
15736
|
+
/<Link\s+[^>]*href\s*=\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15737
|
+
/router\.(push|replace)\s*\(\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15738
|
+
/(?:redirect|permanentRedirect)\s*\(\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15739
|
+
/href\s*:\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15740
|
+
/<a\s+[^>]*href\s*=\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15741
|
+
/revalidatePath\s*\(\s*['"`](\/[^'"`\s{}$]+)['"`]/g,
|
|
15742
|
+
/pathname\s*===?\s*['"`](\/[^'"`\s{}$]+)['"`]/g
|
|
15743
|
+
];
|
|
15744
|
+
function extractPath(match2) {
|
|
15745
|
+
if (match2[2] && match2[2].startsWith("/"))
|
|
15746
|
+
return match2[2];
|
|
15747
|
+
if (match2[1] && match2[1].startsWith("/"))
|
|
15748
|
+
return match2[1];
|
|
15749
|
+
return null;
|
|
15750
|
+
}
|
|
15751
|
+
function shouldSkipPath(path2) {
|
|
15752
|
+
if (/^https?:\/\//.test(path2))
|
|
15753
|
+
return true;
|
|
15754
|
+
if (/^mailto:/.test(path2))
|
|
15755
|
+
return true;
|
|
15756
|
+
if (/^tel:/.test(path2))
|
|
15757
|
+
return true;
|
|
15758
|
+
if (path2 === "#" || path2.startsWith("#"))
|
|
15759
|
+
return true;
|
|
15760
|
+
if (path2.startsWith("/api/") || path2 === "/api")
|
|
15761
|
+
return true;
|
|
15762
|
+
if (path2 === "/_next" || path2.startsWith("/_next/"))
|
|
15763
|
+
return true;
|
|
15764
|
+
return false;
|
|
15765
|
+
}
|
|
15766
|
+
function cleanPath(path2) {
|
|
15767
|
+
return path2.replace(/[?#].*$/, "").replace(/\/$/, "") || "/";
|
|
15768
|
+
}
|
|
15769
|
+
function filePathToRoute(filePath) {
|
|
15770
|
+
let path2 = filePath.replace(/^src\//, "").replace(/^apps\/[^/]+\//, "").replace(/^packages\/[^/]+\//, "");
|
|
15771
|
+
path2 = path2.replace(/^app\//, "").replace(/^pages\//, "");
|
|
15772
|
+
path2 = path2.replace(/\/page\.(ts|tsx|js|jsx|md|mdx)$/, "");
|
|
15773
|
+
path2 = path2.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
15774
|
+
path2 = path2.replace(/\/index$/, "");
|
|
15775
|
+
const segments = path2.split("/").filter((segment) => {
|
|
15776
|
+
if (/^\([^.)][^)]*\)$/.test(segment))
|
|
15777
|
+
return false;
|
|
15778
|
+
if (segment.startsWith("@"))
|
|
15779
|
+
return false;
|
|
15780
|
+
if (/^\(\.+\)/.test(segment))
|
|
15781
|
+
return false;
|
|
15782
|
+
return true;
|
|
15783
|
+
});
|
|
15784
|
+
return "/" + segments.join("/");
|
|
15785
|
+
}
|
|
15786
|
+
function matchesRoute(refPath, routes, routeSegments) {
|
|
15787
|
+
const cleaned = cleanPath(refPath);
|
|
15788
|
+
if (routes.has(cleaned))
|
|
15789
|
+
return true;
|
|
15790
|
+
const refSegments = cleaned.split("/").filter(Boolean);
|
|
15791
|
+
for (const routeSeg of routeSegments) {
|
|
15792
|
+
if (matchSegments(refSegments, routeSeg))
|
|
15793
|
+
return true;
|
|
15794
|
+
if (matchesDynamicSuffix(refSegments, routeSeg))
|
|
15795
|
+
return true;
|
|
15796
|
+
}
|
|
15797
|
+
return false;
|
|
15798
|
+
}
|
|
15799
|
+
function matchesDynamicSuffix(refSegments, routeSegments) {
|
|
15800
|
+
if (refSegments.length >= routeSegments.length)
|
|
15801
|
+
return false;
|
|
15802
|
+
const prefixLen = routeSegments.length - refSegments.length;
|
|
15803
|
+
const prefix = routeSegments.slice(0, prefixLen);
|
|
15804
|
+
if (!prefix.some((s) => /^\[.+\]$/.test(s)))
|
|
15805
|
+
return false;
|
|
15806
|
+
const tail = routeSegments.slice(prefixLen);
|
|
15807
|
+
return matchSegments(refSegments, tail);
|
|
15808
|
+
}
|
|
15809
|
+
function matchSegments(refSegments, routeSegments) {
|
|
15810
|
+
let ri = 0;
|
|
15811
|
+
let si = 0;
|
|
15812
|
+
while (ri < refSegments.length && si < routeSegments.length) {
|
|
15813
|
+
const routeSeg = routeSegments[si];
|
|
15814
|
+
if (/^\[\[?\.\.\./.test(routeSeg))
|
|
15815
|
+
return true;
|
|
15816
|
+
if (/^\[.+\]$/.test(routeSeg)) {
|
|
15817
|
+
ri++;
|
|
15818
|
+
si++;
|
|
15819
|
+
continue;
|
|
15820
|
+
}
|
|
15821
|
+
if (refSegments[ri].toLowerCase() !== routeSeg.toLowerCase())
|
|
15822
|
+
return false;
|
|
15823
|
+
ri++;
|
|
15824
|
+
si++;
|
|
15825
|
+
}
|
|
15826
|
+
return ri === refSegments.length && si === routeSegments.length;
|
|
15827
|
+
}
|
|
15828
|
+
async function scanBrokenLinks(config) {
|
|
15829
|
+
const appDir = config.appSpecificScan ? config.appSpecificScan.appDir : config.dir;
|
|
15830
|
+
const pagePatterns = [
|
|
15831
|
+
"app/**/page.{ts,tsx,js,jsx,md,mdx}",
|
|
15832
|
+
"src/app/**/page.{ts,tsx,js,jsx,md,mdx}",
|
|
15833
|
+
"pages/**/*.{ts,tsx,js,jsx}",
|
|
15834
|
+
"src/pages/**/*.{ts,tsx,js,jsx}"
|
|
15835
|
+
];
|
|
15836
|
+
const pageFiles = await import_fast_glob9.default(pagePatterns, {
|
|
15837
|
+
cwd: appDir,
|
|
15838
|
+
ignore: [...config.ignore.folders, "**/node_modules/**", "**/_*/**"]
|
|
15839
|
+
});
|
|
15840
|
+
if (pageFiles.length === 0) {
|
|
15841
|
+
return { total: 0, links: [] };
|
|
15842
|
+
}
|
|
15843
|
+
const knownRoutes = new Set;
|
|
15844
|
+
const routeSegmentsList = [];
|
|
15845
|
+
knownRoutes.add("/");
|
|
15846
|
+
for (const file of pageFiles) {
|
|
15847
|
+
const route = filePathToRoute(file);
|
|
15848
|
+
knownRoutes.add(route);
|
|
15849
|
+
const segments = route.split("/").filter(Boolean);
|
|
15850
|
+
if (segments.some((s) => s.startsWith("["))) {
|
|
15851
|
+
routeSegmentsList.push(segments);
|
|
15852
|
+
}
|
|
15853
|
+
}
|
|
15854
|
+
if (process.env.DEBUG_PRUNY) {
|
|
15855
|
+
console.log(`[DEBUG] Known routes: ${Array.from(knownRoutes).join(", ")}`);
|
|
15856
|
+
}
|
|
15857
|
+
const refDir = config.appSpecificScan ? config.appSpecificScan.rootDir : config.dir;
|
|
15858
|
+
const ignore = [...config.ignore.folders, ...config.ignore.files, "**/node_modules/**"];
|
|
15859
|
+
const extensions = config.extensions;
|
|
15860
|
+
const globPattern = `**/*{${extensions.join(",")}}`;
|
|
15861
|
+
const sourceFiles = await import_fast_glob9.default(globPattern, {
|
|
15862
|
+
cwd: refDir,
|
|
15863
|
+
ignore,
|
|
15864
|
+
absolute: true
|
|
15865
|
+
});
|
|
15866
|
+
const brokenMap = new Map;
|
|
15867
|
+
for (const file of sourceFiles) {
|
|
15868
|
+
try {
|
|
15869
|
+
const content = readFileSync8(file, "utf-8");
|
|
15870
|
+
for (const pattern of LINK_PATTERNS) {
|
|
15871
|
+
pattern.lastIndex = 0;
|
|
15872
|
+
let match2;
|
|
15873
|
+
while ((match2 = pattern.exec(content)) !== null) {
|
|
15874
|
+
const rawPath = extractPath(match2);
|
|
15875
|
+
if (!rawPath)
|
|
15876
|
+
continue;
|
|
15877
|
+
if (shouldSkipPath(rawPath))
|
|
15878
|
+
continue;
|
|
15879
|
+
const cleaned = cleanPath(rawPath);
|
|
15880
|
+
if (!cleaned || cleaned === "/")
|
|
15881
|
+
continue;
|
|
15882
|
+
if (!matchesRoute(cleaned, knownRoutes, routeSegmentsList)) {
|
|
15883
|
+
const ignorePatterns = [
|
|
15884
|
+
...config.ignore.links || [],
|
|
15885
|
+
...config.ignore.routes
|
|
15886
|
+
];
|
|
15887
|
+
const isIgnored = ignorePatterns.some((ignorePath) => {
|
|
15888
|
+
const pattern2 = ignorePath.replace(/\*/g, ".*");
|
|
15889
|
+
return new RegExp(`^${pattern2}$`).test(cleaned);
|
|
15890
|
+
});
|
|
15891
|
+
if (isIgnored)
|
|
15892
|
+
continue;
|
|
15893
|
+
const lineNumber = content.substring(0, match2.index).split(`
|
|
15894
|
+
`).length;
|
|
15895
|
+
if (!brokenMap.has(cleaned)) {
|
|
15896
|
+
brokenMap.set(cleaned, new Set);
|
|
15897
|
+
}
|
|
15898
|
+
brokenMap.get(cleaned).add(`${file}:${lineNumber}`);
|
|
15899
|
+
}
|
|
15900
|
+
}
|
|
15901
|
+
}
|
|
15902
|
+
} catch (_e) {}
|
|
15903
|
+
}
|
|
15904
|
+
const links = [];
|
|
15905
|
+
for (const [path2, refs] of brokenMap.entries()) {
|
|
15906
|
+
links.push({
|
|
15907
|
+
path: path2,
|
|
15908
|
+
references: Array.from(refs).sort()
|
|
15909
|
+
});
|
|
15910
|
+
}
|
|
15911
|
+
links.sort((a, b) => b.references.length - a.references.length);
|
|
15912
|
+
return {
|
|
15913
|
+
total: links.length,
|
|
15914
|
+
links
|
|
15915
|
+
};
|
|
15916
|
+
}
|
|
15917
|
+
|
|
15732
15918
|
// src/scanner.ts
|
|
15733
15919
|
function extractRoutePath(filePath) {
|
|
15734
15920
|
let path2 = filePath.replace(/^src\//, "").replace(/^apps\/[^/]+\//, "").replace(/^packages\/[^/]+\//, "");
|
|
@@ -15834,19 +16020,19 @@ function extractNestMethodName(content) {
|
|
|
15834
16020
|
return "";
|
|
15835
16021
|
}
|
|
15836
16022
|
function shouldIgnore(path2, ignorePatterns) {
|
|
15837
|
-
const
|
|
16023
|
+
const cleanPath2 = path2.replace(/\\/g, "/").replace(/^\//, "").replace(/^\.\//, "");
|
|
15838
16024
|
return ignorePatterns.some((pattern) => {
|
|
15839
16025
|
let cleanPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
15840
16026
|
const isAbsolute4 = cleanPattern.startsWith("/");
|
|
15841
16027
|
if (isAbsolute4)
|
|
15842
16028
|
cleanPattern = cleanPattern.substring(1);
|
|
15843
|
-
if (minimatch(
|
|
16029
|
+
if (minimatch(cleanPath2, cleanPattern))
|
|
15844
16030
|
return true;
|
|
15845
16031
|
const folderPattern = cleanPattern.endsWith("/") ? cleanPattern : cleanPattern + "/";
|
|
15846
|
-
if (
|
|
16032
|
+
if (cleanPath2.startsWith(folderPattern))
|
|
15847
16033
|
return true;
|
|
15848
16034
|
if (!isAbsolute4 && !cleanPattern.includes("/") && !cleanPattern.includes("*")) {
|
|
15849
|
-
if (
|
|
16035
|
+
if (cleanPath2.endsWith("/" + cleanPattern) || cleanPath2 === cleanPattern)
|
|
15850
16036
|
return true;
|
|
15851
16037
|
}
|
|
15852
16038
|
return false;
|
|
@@ -15863,9 +16049,9 @@ async function detectGlobalPrefix(appDir) {
|
|
|
15863
16049
|
const mainTsAltPath = join7(appDir, "main.ts");
|
|
15864
16050
|
let content;
|
|
15865
16051
|
if (existsSync6(mainTsPath)) {
|
|
15866
|
-
content =
|
|
16052
|
+
content = readFileSync9(mainTsPath, "utf-8");
|
|
15867
16053
|
} else if (existsSync6(mainTsAltPath)) {
|
|
15868
|
-
content =
|
|
16054
|
+
content = readFileSync9(mainTsAltPath, "utf-8");
|
|
15869
16055
|
} else {
|
|
15870
16056
|
return "";
|
|
15871
16057
|
}
|
|
@@ -15927,7 +16113,7 @@ function getVercelCronPaths(dir) {
|
|
|
15927
16113
|
return [];
|
|
15928
16114
|
}
|
|
15929
16115
|
try {
|
|
15930
|
-
const content =
|
|
16116
|
+
const content = readFileSync9(vercelPath, "utf-8");
|
|
15931
16117
|
const config = JSON.parse(content);
|
|
15932
16118
|
if (!config.crons) {
|
|
15933
16119
|
return [];
|
|
@@ -15963,13 +16149,13 @@ async function scan(config) {
|
|
|
15963
16149
|
if (prefix)
|
|
15964
16150
|
detectedGlobalPrefix = prefix;
|
|
15965
16151
|
}
|
|
15966
|
-
const nextFiles = await
|
|
16152
|
+
const nextFiles = await import_fast_glob10.default(activeNextPatterns, {
|
|
15967
16153
|
cwd: scanCwd,
|
|
15968
16154
|
ignore: config.ignore.folders
|
|
15969
16155
|
});
|
|
15970
16156
|
const nextRoutes = nextFiles.map((file) => {
|
|
15971
16157
|
const fullPath = join7(scanCwd, file);
|
|
15972
|
-
const content =
|
|
16158
|
+
const content = readFileSync9(fullPath, "utf-8");
|
|
15973
16159
|
const { methods, methodLines } = extractExportedMethods(content);
|
|
15974
16160
|
return {
|
|
15975
16161
|
type: "nextjs",
|
|
@@ -15983,13 +16169,13 @@ async function scan(config) {
|
|
|
15983
16169
|
};
|
|
15984
16170
|
});
|
|
15985
16171
|
const nestPatterns = ["**/*.controller.ts"];
|
|
15986
|
-
const nestFiles = await
|
|
16172
|
+
const nestFiles = await import_fast_glob10.default(nestPatterns, {
|
|
15987
16173
|
cwd: scanCwd,
|
|
15988
16174
|
ignore: config.ignore.folders
|
|
15989
16175
|
});
|
|
15990
16176
|
const nestRoutes = nestFiles.flatMap((file) => {
|
|
15991
16177
|
const fullPath = join7(scanCwd, file);
|
|
15992
|
-
const content =
|
|
16178
|
+
const content = readFileSync9(fullPath, "utf-8");
|
|
15993
16179
|
const relativePathFromRoot = fullPath.replace(config.appSpecificScan ? config.appSpecificScan.rootDir + "/" : cwd + "/", "");
|
|
15994
16180
|
return extractNestRoutes(relativePathFromRoot, content, detectedGlobalPrefix);
|
|
15995
16181
|
});
|
|
@@ -16009,7 +16195,7 @@ async function scan(config) {
|
|
|
16009
16195
|
}
|
|
16010
16196
|
const referenceScanCwd = config.appSpecificScan ? config.appSpecificScan.rootDir : cwd;
|
|
16011
16197
|
const extGlob = `**/*{${config.extensions.join(",")}}`;
|
|
16012
|
-
const sourceFiles = await
|
|
16198
|
+
const sourceFiles = await import_fast_glob10.default(extGlob, {
|
|
16013
16199
|
cwd: referenceScanCwd,
|
|
16014
16200
|
ignore: [...config.ignore.folders, ...config.ignore.files]
|
|
16015
16201
|
});
|
|
@@ -16018,7 +16204,7 @@ async function scan(config) {
|
|
|
16018
16204
|
for (const file of sourceFiles) {
|
|
16019
16205
|
const filePath = join7(referenceScanCwd, file);
|
|
16020
16206
|
try {
|
|
16021
|
-
const content =
|
|
16207
|
+
const content = readFileSync9(filePath, "utf-8");
|
|
16022
16208
|
const refs = extractApiReferences(content);
|
|
16023
16209
|
if (refs.length > 0) {
|
|
16024
16210
|
fileReferences.set(file, refs);
|
|
@@ -16065,6 +16251,7 @@ async function scan(config) {
|
|
|
16065
16251
|
routes,
|
|
16066
16252
|
publicAssets,
|
|
16067
16253
|
missingAssets: await scanMissingAssets(config),
|
|
16254
|
+
brokenLinks: await scanBrokenLinks(config),
|
|
16068
16255
|
unusedFiles,
|
|
16069
16256
|
unusedExports: await scanUnusedExports(config).then((result) => {
|
|
16070
16257
|
const filtered = result.exports.filter((exp) => !exp.file.endsWith(".controller.ts") && !exp.file.endsWith(".controller.tsx"));
|
|
@@ -16076,8 +16263,8 @@ async function scan(config) {
|
|
|
16076
16263
|
}
|
|
16077
16264
|
|
|
16078
16265
|
// src/config.ts
|
|
16079
|
-
var
|
|
16080
|
-
import { existsSync as existsSync7, readFileSync as
|
|
16266
|
+
var import_fast_glob11 = __toESM(require_out4(), 1);
|
|
16267
|
+
import { existsSync as existsSync7, readFileSync as readFileSync10 } from "node:fs";
|
|
16081
16268
|
import { join as join8, resolve as resolve2, relative as relative4, dirname as dirname4 } from "node:path";
|
|
16082
16269
|
var DEFAULT_CONFIG = {
|
|
16083
16270
|
dir: "./",
|
|
@@ -16092,7 +16279,8 @@ var DEFAULT_CONFIG = {
|
|
|
16092
16279
|
"**/.git/**",
|
|
16093
16280
|
"**/coverage/**"
|
|
16094
16281
|
],
|
|
16095
|
-
files: []
|
|
16282
|
+
files: [],
|
|
16283
|
+
links: []
|
|
16096
16284
|
},
|
|
16097
16285
|
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
|
16098
16286
|
nestGlobalPrefix: "",
|
|
@@ -16100,7 +16288,7 @@ var DEFAULT_CONFIG = {
|
|
|
16100
16288
|
};
|
|
16101
16289
|
function loadConfig(options) {
|
|
16102
16290
|
const cwd = options.dir || "./";
|
|
16103
|
-
const configFiles =
|
|
16291
|
+
const configFiles = import_fast_glob11.default.sync(["**/pruny.config.json", "**/.prunyrc.json", "**/.prunyrc"], {
|
|
16104
16292
|
cwd,
|
|
16105
16293
|
ignore: DEFAULT_CONFIG.ignore.folders,
|
|
16106
16294
|
absolute: true
|
|
@@ -16118,7 +16306,8 @@ function loadConfig(options) {
|
|
|
16118
16306
|
const mergedIgnore = {
|
|
16119
16307
|
routes: [...DEFAULT_CONFIG.ignore.routes || []],
|
|
16120
16308
|
folders: [...DEFAULT_CONFIG.ignore.folders || []],
|
|
16121
|
-
files: [...DEFAULT_CONFIG.ignore.files || []]
|
|
16309
|
+
files: [...DEFAULT_CONFIG.ignore.files || []],
|
|
16310
|
+
links: [...DEFAULT_CONFIG.ignore.links || []]
|
|
16122
16311
|
};
|
|
16123
16312
|
let mergedExtensions = [...DEFAULT_CONFIG.extensions];
|
|
16124
16313
|
let nestGlobalPrefix = DEFAULT_CONFIG.nestGlobalPrefix;
|
|
@@ -16126,7 +16315,7 @@ function loadConfig(options) {
|
|
|
16126
16315
|
let excludePublic = options.excludePublic ?? false;
|
|
16127
16316
|
for (const configPath of configFiles) {
|
|
16128
16317
|
try {
|
|
16129
|
-
const content =
|
|
16318
|
+
const content = readFileSync10(configPath, "utf-8");
|
|
16130
16319
|
const config = JSON.parse(content);
|
|
16131
16320
|
const configDir = dirname4(configPath);
|
|
16132
16321
|
const relDir = relative4(cwd, configDir);
|
|
@@ -16142,6 +16331,8 @@ function loadConfig(options) {
|
|
|
16142
16331
|
mergedIgnore.folders.push(...config.ignore.folders.map(prefixPattern));
|
|
16143
16332
|
if (config.ignore?.files)
|
|
16144
16333
|
mergedIgnore.files.push(...config.ignore.files.map(prefixPattern));
|
|
16334
|
+
if (config.ignore?.links)
|
|
16335
|
+
mergedIgnore.links.push(...config.ignore.links);
|
|
16145
16336
|
if (config.extensions)
|
|
16146
16337
|
mergedExtensions = [...new Set([...mergedExtensions, ...config.extensions])];
|
|
16147
16338
|
if (config.nestGlobalPrefix)
|
|
@@ -16159,6 +16350,7 @@ function loadConfig(options) {
|
|
|
16159
16350
|
mergedIgnore.routes = [...new Set(mergedIgnore.routes)];
|
|
16160
16351
|
mergedIgnore.folders = [...new Set(mergedIgnore.folders)];
|
|
16161
16352
|
mergedIgnore.files = [...new Set(mergedIgnore.files)];
|
|
16353
|
+
mergedIgnore.links = [...new Set(mergedIgnore.links)];
|
|
16162
16354
|
return {
|
|
16163
16355
|
dir: cwd,
|
|
16164
16356
|
ignore: mergedIgnore,
|
|
@@ -16173,7 +16365,7 @@ function parseGitIgnore(dir) {
|
|
|
16173
16365
|
if (!existsSync7(gitIgnorePath))
|
|
16174
16366
|
return [];
|
|
16175
16367
|
try {
|
|
16176
|
-
const content =
|
|
16368
|
+
const content = readFileSync10(gitIgnorePath, "utf-8");
|
|
16177
16369
|
return content.split(`
|
|
16178
16370
|
`).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).map((pattern) => {
|
|
16179
16371
|
if (pattern.startsWith("/") || pattern.startsWith("**/"))
|
|
@@ -16448,6 +16640,10 @@ function filterResults(result, filterPattern) {
|
|
|
16448
16640
|
result.unusedExports.total = result.unusedExports.exports.length;
|
|
16449
16641
|
result.unusedExports.unused = result.unusedExports.exports.length;
|
|
16450
16642
|
}
|
|
16643
|
+
if (result.brokenLinks) {
|
|
16644
|
+
result.brokenLinks.links = result.brokenLinks.links.filter((l) => matchesFilter(l.path, filter2));
|
|
16645
|
+
result.brokenLinks.total = result.brokenLinks.links.length;
|
|
16646
|
+
}
|
|
16451
16647
|
result.total = result.routes.length;
|
|
16452
16648
|
result.used = result.routes.filter((r) => r.used).length;
|
|
16453
16649
|
result.unused = result.routes.filter((r) => !r.used).length;
|
|
@@ -16525,6 +16721,17 @@ function printDetailedReport(result) {
|
|
|
16525
16721
|
}
|
|
16526
16722
|
console.log("");
|
|
16527
16723
|
}
|
|
16724
|
+
if (result.brokenLinks && result.brokenLinks.total > 0) {
|
|
16725
|
+
console.log(source_default.red.bold(`\uD83D\uDD17 Broken Internal Links:
|
|
16726
|
+
`));
|
|
16727
|
+
for (const link of result.brokenLinks.links) {
|
|
16728
|
+
console.log(source_default.red(` ${link.path}`));
|
|
16729
|
+
for (const ref of link.references) {
|
|
16730
|
+
console.log(source_default.dim(` → ${ref}`));
|
|
16731
|
+
}
|
|
16732
|
+
}
|
|
16733
|
+
console.log("");
|
|
16734
|
+
}
|
|
16528
16735
|
if (!hasUnusedItems(result)) {
|
|
16529
16736
|
console.log(source_default.green(`✅ Everything is used! Clean as a whistle.
|
|
16530
16737
|
`));
|
|
@@ -16538,10 +16745,11 @@ function countIssues(result) {
|
|
|
16538
16745
|
const partialRoutes = result.routes.filter((r) => r.used && r.unusedMethods.length > 0).length;
|
|
16539
16746
|
const unusedAssets = result.publicAssets ? result.publicAssets.unused : 0;
|
|
16540
16747
|
const missingAssets = result.missingAssets ? result.missingAssets.total : 0;
|
|
16748
|
+
const brokenLinks = result.brokenLinks ? result.brokenLinks.total : 0;
|
|
16541
16749
|
const unusedFiles = result.unusedFiles ? result.unusedFiles.unused : 0;
|
|
16542
16750
|
const unusedExports = result.unusedExports ? result.unusedExports.unused : 0;
|
|
16543
16751
|
const unusedServices = result.unusedServices ? result.unusedServices.total : 0;
|
|
16544
|
-
return unusedRoutes + partialRoutes + unusedAssets + missingAssets + unusedFiles + unusedExports + unusedServices;
|
|
16752
|
+
return unusedRoutes + partialRoutes + unusedAssets + missingAssets + brokenLinks + unusedFiles + unusedExports + unusedServices;
|
|
16545
16753
|
}
|
|
16546
16754
|
async function handleFixes(result, config, options, showBack) {
|
|
16547
16755
|
const gitRoot = findGitRoot(config.dir);
|
|
@@ -16619,6 +16827,11 @@ Analyzing cascading impact...`));
|
|
|
16619
16827
|
const title = count > 0 ? `⚠ Missing Assets (Broken Links) (${count})` : `✅ Missing Assets (0) - All good!`;
|
|
16620
16828
|
choices.push({ title, value: "missing-assets" });
|
|
16621
16829
|
}
|
|
16830
|
+
if (result.brokenLinks) {
|
|
16831
|
+
const count = result.brokenLinks.total;
|
|
16832
|
+
const title = count > 0 ? `\uD83D\uDD17 Broken Internal Links (${count})` : `✅ Internal Links (0) - All good!`;
|
|
16833
|
+
choices.push({ title, value: "broken-links" });
|
|
16834
|
+
}
|
|
16622
16835
|
if (showBack) {
|
|
16623
16836
|
choices.push({ title: source_default.cyan("← Back"), value: "back" });
|
|
16624
16837
|
}
|
|
@@ -16688,7 +16901,8 @@ Analyzing cascading impact...`));
|
|
|
16688
16901
|
exports: [],
|
|
16689
16902
|
files: [],
|
|
16690
16903
|
assets: [],
|
|
16691
|
-
missingAssets: []
|
|
16904
|
+
missingAssets: [],
|
|
16905
|
+
brokenLinks: []
|
|
16692
16906
|
};
|
|
16693
16907
|
if (selected === "routes" || selected === "dry-run-json" || action === "dry-run") {
|
|
16694
16908
|
dryRunReport.routes = targetRoutes.map((r) => ({
|
|
@@ -16759,6 +16973,14 @@ Analyzing cascading impact...`));
|
|
|
16759
16973
|
}));
|
|
16760
16974
|
dryRunReport.uniqueFiles = missingList.length;
|
|
16761
16975
|
}
|
|
16976
|
+
if (selected === "broken-links") {
|
|
16977
|
+
const brokenList = result.brokenLinks?.links || [];
|
|
16978
|
+
dryRunReport.brokenLinks = brokenList.map((l) => ({
|
|
16979
|
+
path: l.path,
|
|
16980
|
+
references: l.references
|
|
16981
|
+
}));
|
|
16982
|
+
dryRunReport.uniqueFiles = brokenList.length;
|
|
16983
|
+
}
|
|
16762
16984
|
const reportPath = join10(process.cwd(), "pruny-dry-run.json");
|
|
16763
16985
|
writeFileSync3(reportPath, JSON.stringify(dryRunReport, null, 2));
|
|
16764
16986
|
console.log(source_default.green(`
|
|
@@ -16767,6 +16989,25 @@ Analyzing cascading impact...`));
|
|
|
16767
16989
|
}
|
|
16768
16990
|
const selectedList = options.cleanup ? options.cleanup.split(",").map((s) => s.trim()) : [selected];
|
|
16769
16991
|
let fixedSomething = false;
|
|
16992
|
+
if (selectedList.includes("broken-links")) {
|
|
16993
|
+
if (result.brokenLinks && result.brokenLinks.total > 0) {
|
|
16994
|
+
console.log(source_default.yellow.bold(`
|
|
16995
|
+
\uD83D\uDD17 Broken Internal Links Detected:`));
|
|
16996
|
+
console.log(source_default.gray(" (These links point to pages that don't exist. Please fix or remove them:)"));
|
|
16997
|
+
for (const link of result.brokenLinks.links) {
|
|
16998
|
+
console.log(source_default.red.bold(`
|
|
16999
|
+
❌ ${link.path}`));
|
|
17000
|
+
for (const ref of link.references) {
|
|
17001
|
+
console.log(source_default.gray(` ➜ ${ref}`));
|
|
17002
|
+
}
|
|
17003
|
+
}
|
|
17004
|
+
console.log(source_default.yellow(`
|
|
17005
|
+
Create the missing pages or update the links to valid routes.`));
|
|
17006
|
+
} else {
|
|
17007
|
+
console.log(source_default.green(`
|
|
17008
|
+
✅ No broken internal links found! All links are valid.`));
|
|
17009
|
+
}
|
|
17010
|
+
}
|
|
16770
17011
|
if (selectedList.includes("missing-assets")) {
|
|
16771
17012
|
if (result.missingAssets && result.missingAssets.total > 0) {
|
|
16772
17013
|
console.log(source_default.yellow.bold(`
|
|
@@ -17149,6 +17390,14 @@ function printSummaryTable(result, context) {
|
|
|
17149
17390
|
Unused: result.missingAssets.total
|
|
17150
17391
|
});
|
|
17151
17392
|
}
|
|
17393
|
+
if (result.brokenLinks && result.brokenLinks.total > 0) {
|
|
17394
|
+
summary.push({
|
|
17395
|
+
Category: source_default.red.bold("\uD83D\uDD17 Broken Links"),
|
|
17396
|
+
Total: result.brokenLinks.total,
|
|
17397
|
+
Used: "-",
|
|
17398
|
+
Unused: result.brokenLinks.total
|
|
17399
|
+
});
|
|
17400
|
+
}
|
|
17152
17401
|
if (result.unusedFiles)
|
|
17153
17402
|
summary.push({ Category: "Code Files (.ts/.js)", Total: result.unusedFiles.used + result.unusedFiles.unused, Used: result.unusedFiles.used, Unused: result.unusedFiles.unused });
|
|
17154
17403
|
if (result.unusedExports)
|
|
@@ -17184,6 +17433,19 @@ function printSummaryTable(result, context) {
|
|
|
17184
17433
|
console.log(source_default.yellow(`
|
|
17185
17434
|
These files are referenced in code but don't exist. Update the links or create the files.`));
|
|
17186
17435
|
}
|
|
17436
|
+
if (result.brokenLinks && result.brokenLinks.total > 0) {
|
|
17437
|
+
console.log(source_default.red.bold(`
|
|
17438
|
+
\uD83D\uDD17 Broken Internal Links:
|
|
17439
|
+
`));
|
|
17440
|
+
for (const link of result.brokenLinks.links) {
|
|
17441
|
+
console.log(source_default.red(` ✗ ${link.path}`));
|
|
17442
|
+
for (const ref of link.references) {
|
|
17443
|
+
console.log(source_default.dim(` → ${ref}`));
|
|
17444
|
+
}
|
|
17445
|
+
}
|
|
17446
|
+
console.log(source_default.yellow(`
|
|
17447
|
+
These links point to pages/routes that don't exist. Create the pages or fix the links.`));
|
|
17448
|
+
}
|
|
17187
17449
|
}
|
|
17188
17450
|
function printConsolidatedTable(allResults) {
|
|
17189
17451
|
console.log(source_default.bold(`\uD83D\uDCCA Monorepo Summary
|