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.
Files changed (3) hide show
  1. package/README.md +132 -44
  2. package/dist/index.js +286 -24
  3. 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 API routes & Nest.js Controllers. 🪓
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
- ## Usage
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 | Description |
18
- | :--------------------------- | :--------------------------------------------------------------------------------------- |
19
- | `pruny` | Scan for unused items interactively (monorepo-aware). |
20
- | `pruny --dir <path>` | Set the target project directory (default: `./`). |
21
- | `pruny --app <name>` | Scan a specific application within a monorepo. |
22
- | `pruny --folder <path>` | Scan a specific folder OR sub-directory for routes/controllers. |
23
- | `pruny --fix` | Automatically delete unused items found during scan. |
24
- | `pruny --cleanup <items>` | Quick cleanup: `routes`, `exports`, `public`, `files`. (e.g. `--cleanup routes,exports`) |
25
- | `pruny --filter <pattern>` | Filter results by string (app name, file path, etc). |
26
- | `pruny --ignore-apps <list>` | Comma-separated list of apps to skip in monorepos. |
27
- | `pruny --no-public` | Disable scanning of public assets. |
28
- | `pruny --json` | Output scan results as JSON for automation. |
29
- | `pruny -v, --verbose` | Show detailed debug logging and trace info. |
30
- | `pruny init` | Create a `pruny.config.json` configuration file. |
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
- ### Public Asset Scanning (New in v1.1.0)
85
+ ### Config File Locations
33
86
 
34
- Pruny automatically scans your `public/` directory for unused images and files.
87
+ Pruny searches for config files recursively across your project:
88
+ - `pruny.config.json`
89
+ - `.prunyrc.json`
90
+ - `.prunyrc`
35
91
 
36
- - **Enabled by default**: Run `npx pruny` and it will show unused assets, excluding ignored folders.
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
- ## Config
94
+ ## Multi-Tenant / Subdomain Routing
44
95
 
45
- Create `pruny.config.json` (optional):
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
- "routes": ["/api/webhooks/**", "/api/cron/**"],
52
- "folders": ["node_modules", ".next", "dist"],
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
- ## Features
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
- - 🔍 Detects unused Next.js API routes & Nest.js Controller methods
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
- ## How it works
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
- 1. **Next.js**: Finds all `app/api/**/route.ts` files.
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 import_fast_glob9 = __toESM(require_out4(), 1);
12571
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
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 cleanPath = path2.replace(/\\/g, "/").replace(/^\//, "").replace(/^\.\//, "");
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(cleanPath, cleanPattern))
16029
+ if (minimatch(cleanPath2, cleanPattern))
15844
16030
  return true;
15845
16031
  const folderPattern = cleanPattern.endsWith("/") ? cleanPattern : cleanPattern + "/";
15846
- if (cleanPath.startsWith(folderPattern))
16032
+ if (cleanPath2.startsWith(folderPattern))
15847
16033
  return true;
15848
16034
  if (!isAbsolute4 && !cleanPattern.includes("/") && !cleanPattern.includes("*")) {
15849
- if (cleanPath.endsWith("/" + cleanPattern) || cleanPath === cleanPattern)
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 = readFileSync8(mainTsPath, "utf-8");
16052
+ content = readFileSync9(mainTsPath, "utf-8");
15867
16053
  } else if (existsSync6(mainTsAltPath)) {
15868
- content = readFileSync8(mainTsAltPath, "utf-8");
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 = readFileSync8(vercelPath, "utf-8");
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 import_fast_glob9.default(activeNextPatterns, {
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 = readFileSync8(fullPath, "utf-8");
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 import_fast_glob9.default(nestPatterns, {
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 = readFileSync8(fullPath, "utf-8");
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 import_fast_glob9.default(extGlob, {
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 = readFileSync8(filePath, "utf-8");
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 import_fast_glob10 = __toESM(require_out4(), 1);
16080
- import { existsSync as existsSync7, readFileSync as readFileSync9 } from "node:fs";
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 = import_fast_glob10.default.sync(["**/pruny.config.json", "**/.prunyrc.json", "**/.prunyrc"], {
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 = readFileSync9(configPath, "utf-8");
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 = readFileSync9(gitIgnorePath, "utf-8");
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pruny",
3
- "version": "1.36.1",
3
+ "version": "1.38.0",
4
4
  "description": "Find and remove unused Next.js API routes & Nest.js Controllers",
5
5
  "type": "module",
6
6
  "files": [