pruny 1.0.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.
@@ -0,0 +1,56 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+ push:
7
+ tags:
8
+ - "v*"
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Setup Bun
17
+ uses: oven-sh/setup-bun@v2
18
+ with:
19
+ bun-version: latest
20
+
21
+ - name: Install dependencies
22
+ run: bun install
23
+
24
+ - name: Build
25
+ run: bun run build
26
+
27
+ publish:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: read
32
+ id-token: write
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Setup Bun
37
+ uses: oven-sh/setup-bun@v2
38
+ with:
39
+ bun-version: latest
40
+
41
+ - name: Setup Node.js
42
+ uses: actions/setup-node@v4
43
+ with:
44
+ node-version: "20"
45
+ registry-url: "https://registry.npmjs.org"
46
+
47
+ - name: Install dependencies
48
+ run: bun install
49
+
50
+ - name: Build
51
+ run: bun run build
52
+
53
+ - name: Publish to npm
54
+ run: npm publish --access public
55
+ env:
56
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # pruny
2
+
3
+ Find and remove unused Next.js API routes. 🪓
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g pruny
9
+ # or
10
+ npx pruny
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Scan current directory
17
+ pruny
18
+
19
+ # Scan specific folder
20
+ pruny --dir ./src
21
+
22
+ # Delete unused routes
23
+ pruny --fix
24
+
25
+ # Output as JSON
26
+ pruny --json
27
+
28
+ # Verbose output
29
+ pruny -v
30
+ ```
31
+
32
+ ## Config
33
+
34
+ Create `pruny.config.json` (optional):
35
+
36
+ ```json
37
+ {
38
+ "dir": "./",
39
+ "ignore": {
40
+ "routes": ["/api/webhooks/**", "/api/cron/**"],
41
+ "folders": ["node_modules", ".next", "dist"],
42
+ "files": ["*.test.ts"]
43
+ },
44
+ "extensions": [".ts", ".tsx", ".js", ".jsx"]
45
+ }
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - 🔍 Detects unused Next.js API routes
51
+ - 🗑️ `--fix` flag to delete unused routes
52
+ - ⚡ Auto-detects `vercel.json` cron routes
53
+ - 📁 Default ignores: `node_modules`, `.next`, `dist`, `.git`
54
+ - 🎨 Beautiful CLI output
55
+
56
+ ## How it works
57
+
58
+ 1. Finds all `app/api/**/route.ts` files
59
+ 2. Scans codebase for `fetch('/api/...')` patterns
60
+ 3. Reports routes with no references
61
+ 4. `--fix` deletes the route folder
62
+
63
+ ## License
64
+
65
+ MIT
package/bin/pruny.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/index.js');
package/bun.lock ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "unused_variable",
7
+ "dependencies": {
8
+ "chalk": "^5.3.0",
9
+ "commander": "^12.0.0",
10
+ "fast-glob": "^3.3.2",
11
+ "minimatch": "^10.1.2",
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "latest",
15
+ "@types/minimatch": "^6.0.0",
16
+ "typescript": "^5.0.0",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
22
+
23
+ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
24
+
25
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
26
+
27
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
28
+
29
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
30
+
31
+ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
32
+
33
+ "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="],
34
+
35
+ "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
36
+
37
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
38
+
39
+ "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
40
+
41
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
42
+
43
+ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
44
+
45
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
46
+
47
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
48
+
49
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
50
+
51
+ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
52
+
53
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
54
+
55
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
56
+
57
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
58
+
59
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
60
+
61
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
62
+
63
+ "minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
64
+
65
+ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
66
+
67
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
68
+
69
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
70
+
71
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
72
+
73
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
74
+
75
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
76
+
77
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
78
+ }
79
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pruny",
3
+ "version": "1.0.0",
4
+ "description": "Find and remove unused Next.js API routes",
5
+ "type": "module",
6
+ "bin": {
7
+ "pruny": "./bin/pruny.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "scripts": {
12
+ "build": "bun build ./src/index.ts --outdir ./dist --target node",
13
+ "dev": "bun run ./src/index.ts",
14
+ "prepublishOnly": "bun run build"
15
+ },
16
+ "keywords": [
17
+ "unused",
18
+ "api",
19
+ "routes",
20
+ "nextjs",
21
+ "cleanup",
22
+ "lint"
23
+ ],
24
+ "author": "webnaresh",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/webnaresh/pruny"
29
+ },
30
+ "dependencies": {
31
+ "chalk": "^5.3.0",
32
+ "commander": "^12.0.0",
33
+ "fast-glob": "^3.3.2",
34
+ "minimatch": "^10.1.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "@types/minimatch": "^6.0.0",
39
+ "typescript": "^5.0.0"
40
+ }
41
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Config } from './types.js';
4
+
5
+ const DEFAULT_CONFIG: Config = {
6
+ dir: './',
7
+ ignore: {
8
+ routes: [],
9
+ folders: ['node_modules', '.next', 'dist', '.git', 'coverage', '.turbo'],
10
+ files: ['*.test.ts', '*.spec.ts', '*.test.tsx', '*.spec.tsx'],
11
+ },
12
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
13
+ };
14
+
15
+ interface CLIOptions {
16
+ dir?: string;
17
+ config?: string;
18
+ }
19
+
20
+ /**
21
+ * Load config from file or use defaults
22
+ */
23
+ export function loadConfig(options: CLIOptions): Config {
24
+ const configPath = options.config || findConfigFile(options.dir || './');
25
+
26
+ let fileConfig: Partial<Config> = {};
27
+
28
+ if (configPath && existsSync(configPath)) {
29
+ try {
30
+ const content = readFileSync(configPath, 'utf-8');
31
+ fileConfig = JSON.parse(content);
32
+ } catch {
33
+ // Ignore parse errors, use defaults
34
+ }
35
+ }
36
+
37
+ // Merge configs
38
+ return {
39
+ dir: options.dir || fileConfig.dir || DEFAULT_CONFIG.dir,
40
+ ignore: {
41
+ routes: fileConfig.ignore?.routes || DEFAULT_CONFIG.ignore.routes,
42
+ folders: fileConfig.ignore?.folders || DEFAULT_CONFIG.ignore.folders,
43
+ files: fileConfig.ignore?.files || DEFAULT_CONFIG.ignore.files,
44
+ },
45
+ extensions: fileConfig.extensions || DEFAULT_CONFIG.extensions,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Find config file in directory
51
+ */
52
+ function findConfigFile(dir: string): string | null {
53
+ const candidates = ['pruny.config.json', '.prunyrc.json', '.prunyrc'];
54
+
55
+ for (const name of candidates) {
56
+ const path = join(dir, name);
57
+ if (existsSync(path)) {
58
+ return path;
59
+ }
60
+ }
61
+
62
+ return null;
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import { rmSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { scan } from './scanner.js';
8
+ import { loadConfig } from './config.js';
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('pruny')
14
+ .description('Find and remove unused Next.js API routes')
15
+ .version('1.0.0')
16
+ .option('-d, --dir <path>', 'Target directory to scan', './')
17
+ .option('--fix', 'Delete unused API routes')
18
+ .option('-c, --config <path>', 'Path to config file')
19
+ .option('--json', 'Output as JSON')
20
+ .option('-v, --verbose', 'Show detailed info')
21
+ .action(async (options) => {
22
+ const config = loadConfig({
23
+ dir: options.dir,
24
+ config: options.config,
25
+ });
26
+
27
+ // Resolve absolute path
28
+ const absoluteDir = config.dir.startsWith('/')
29
+ ? config.dir
30
+ : join(process.cwd(), config.dir);
31
+ config.dir = absoluteDir;
32
+
33
+ if (options.verbose) {
34
+ console.log(chalk.dim('\nConfig:'));
35
+ console.log(chalk.dim(JSON.stringify(config, null, 2)));
36
+ console.log('');
37
+ }
38
+
39
+ console.log(chalk.bold('\n🔍 Scanning for unused API routes...\n'));
40
+
41
+ try {
42
+ const result = await scan(config);
43
+
44
+ if (options.json) {
45
+ console.log(JSON.stringify(result, null, 2));
46
+ return;
47
+ }
48
+
49
+ // Summary
50
+ console.log(chalk.bold('📊 Results\n'));
51
+ console.log(` Total routes: ${result.total}`);
52
+ console.log(chalk.green(` Used routes: ${result.used}`));
53
+ console.log(chalk.red(` Unused routes: ${result.unused}`));
54
+ console.log('');
55
+
56
+ if (result.total === 0) {
57
+ console.log(chalk.yellow('⚠️ No API routes found.\n'));
58
+ return;
59
+ }
60
+
61
+ const unused = result.routes.filter((r) => !r.used);
62
+
63
+ if (unused.length === 0) {
64
+ console.log(chalk.green('✅ All API routes are used!\n'));
65
+ return;
66
+ }
67
+
68
+ // List unused routes
69
+ console.log(chalk.red.bold('❌ Unused routes:\n'));
70
+ for (const route of unused) {
71
+ console.log(chalk.red(` ${route.path}`));
72
+ console.log(chalk.dim(` → ${route.filePath}`));
73
+ }
74
+ console.log('');
75
+
76
+ // Show used routes in verbose mode
77
+ if (options.verbose) {
78
+ const used = result.routes.filter((r) => r.used);
79
+ if (used.length > 0) {
80
+ console.log(chalk.green.bold('✅ Used routes:\n'));
81
+ for (const route of used) {
82
+ console.log(chalk.green(` ${route.path}`));
83
+ if (route.references.length > 0) {
84
+ for (const ref of route.references.slice(0, 3)) {
85
+ console.log(chalk.dim(` ← ${ref}`));
86
+ }
87
+ if (route.references.length > 3) {
88
+ console.log(
89
+ chalk.dim(` ... and ${route.references.length - 3} more`)
90
+ );
91
+ }
92
+ }
93
+ }
94
+ console.log('');
95
+ }
96
+ }
97
+
98
+ // --fix: Delete unused routes
99
+ if (options.fix) {
100
+ console.log(chalk.yellow.bold('🗑️ Deleting unused routes...\n'));
101
+
102
+ for (const route of unused) {
103
+ const routeDir = dirname(join(config.dir, route.filePath));
104
+ try {
105
+ rmSync(routeDir, { recursive: true, force: true });
106
+ console.log(chalk.red(` Deleted: ${route.filePath}`));
107
+ } catch (err) {
108
+ console.log(
109
+ chalk.yellow(` Failed to delete: ${route.filePath}`)
110
+ );
111
+ }
112
+ }
113
+
114
+ console.log(
115
+ chalk.green(`\n✅ Deleted ${unused.length} unused route(s).\n`)
116
+ );
117
+ } else {
118
+ console.log(
119
+ chalk.dim('💡 Run with --fix to delete unused routes.\n')
120
+ );
121
+ }
122
+ } catch (err) {
123
+ console.error(chalk.red('Error scanning:'), err);
124
+ process.exit(1);
125
+ }
126
+ });
127
+
128
+ program.parse();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Regex patterns to detect API route usage in source files
3
+ */
4
+ export const API_PATTERNS: RegExp[] = [
5
+ // fetch('/api/...')
6
+ /fetch\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
7
+
8
+ // fetch(`/api/...`)
9
+ /fetch\s*\(\s*`\/api\/([^`\s)]+)`/g,
10
+
11
+ // axios.get('/api/...')
12
+ /axios\.\w+\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
13
+
14
+ // useSWR('/api/...')
15
+ /useSWR\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
16
+
17
+ // useQuery with /api/
18
+ /queryFn.*['"`]\/api\/([^'"`\s)]+)['"`]/g,
19
+
20
+ // Generic string containing /api/
21
+ /['"`]\/api\/([^'"`\s]+)['"`]/g,
22
+ ];
23
+
24
+ /**
25
+ * Extract all API paths referenced in content
26
+ */
27
+ export function extractApiPaths(content: string): string[] {
28
+ const paths = new Set<string>();
29
+
30
+ for (const pattern of API_PATTERNS) {
31
+ // Reset regex lastIndex
32
+ pattern.lastIndex = 0;
33
+
34
+ let match: RegExpExecArray | null;
35
+ while ((match = pattern.exec(content)) !== null) {
36
+ // Full path including /api/
37
+ const fullMatch = match[0];
38
+ const pathMatch = fullMatch.match(/\/api\/[^'"`\s)]+/);
39
+ if (pathMatch) {
40
+ paths.add(pathMatch[0]);
41
+ }
42
+ }
43
+ }
44
+
45
+ return Array.from(paths);
46
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,188 @@
1
+ import fg from 'fast-glob';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { extractApiPaths } from './patterns.js';
5
+ import type { Config, ApiRoute, ScanResult, VercelConfig } from './types.js';
6
+ import { minimatch } from 'minimatch';
7
+
8
+ /**
9
+ * Extract route path from file path
10
+ * e.g., app/api/users/route.ts -> /api/users
11
+ */
12
+ function extractRoutePath(filePath: string): string {
13
+ // Remove src/ prefix if present
14
+ let path = filePath.replace(/^src\//, '');
15
+
16
+ // Remove app/ prefix
17
+ path = path.replace(/^app\//, '');
18
+
19
+ // Remove route.{ts,tsx,js,jsx} suffix
20
+ path = path.replace(/\/route\.(ts|tsx|js|jsx)$/, '');
21
+
22
+ return '/' + path;
23
+ }
24
+
25
+ /**
26
+ * Check if a route matches any ignore pattern
27
+ */
28
+ function shouldIgnoreRoute(routePath: string, ignorePatterns: string[]): boolean {
29
+ return ignorePatterns.some((pattern) => minimatch(routePath, pattern));
30
+ }
31
+
32
+ /**
33
+ * Normalize API path for comparison
34
+ * Removes trailing slashes, query params, dynamic segments for matching
35
+ */
36
+ function normalizeApiPath(path: string): string {
37
+ return path
38
+ .replace(/\/$/, '') // Remove trailing slash
39
+ .replace(/\?.*$/, '') // Remove query params
40
+ .replace(/\$\{[^}]+\}/g, '*') // Replace template literals with wildcard
41
+ .toLowerCase();
42
+ }
43
+
44
+ /**
45
+ * Check if a route is referenced by any of the found paths
46
+ */
47
+ function isRouteReferenced(routePath: string, foundPaths: string[]): boolean {
48
+ const normalizedRoute = normalizeApiPath(routePath);
49
+
50
+ return foundPaths.some((foundPath) => {
51
+ const normalizedFound = normalizeApiPath(foundPath);
52
+
53
+ // Exact match
54
+ if (normalizedRoute === normalizedFound) return true;
55
+
56
+ // Route is prefix of found path (for dynamic routes)
57
+ if (normalizedFound.startsWith(normalizedRoute)) return true;
58
+
59
+ // Found path matches route pattern
60
+ if (minimatch(normalizedFound, normalizedRoute)) return true;
61
+
62
+ return false;
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Load vercel.json and get cron paths
68
+ */
69
+ function getVercelCronPaths(dir: string): string[] {
70
+ const vercelPath = join(dir, 'vercel.json');
71
+
72
+ if (!existsSync(vercelPath)) {
73
+ return [];
74
+ }
75
+
76
+ try {
77
+ const content = readFileSync(vercelPath, 'utf-8');
78
+ const config: VercelConfig = JSON.parse(content);
79
+
80
+ if (!config.crons) {
81
+ return [];
82
+ }
83
+
84
+ return config.crons.map((cron) => cron.path);
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Scan for unused API routes
92
+ */
93
+ export async function scan(config: Config): Promise<ScanResult> {
94
+ const cwd = config.dir;
95
+
96
+ // 1. Find all API route files
97
+ const routePatterns = [
98
+ 'app/api/**/route.{ts,tsx,js,jsx}',
99
+ 'src/app/api/**/route.{ts,tsx,js,jsx}',
100
+ ];
101
+
102
+ const routeFiles = await fg(routePatterns, {
103
+ cwd,
104
+ ignore: config.ignore.folders,
105
+ });
106
+
107
+ if (routeFiles.length === 0) {
108
+ return { total: 0, used: 0, unused: 0, routes: [] };
109
+ }
110
+
111
+ // 2. Build route map
112
+ const routes: ApiRoute[] = routeFiles.map((file) => ({
113
+ path: extractRoutePath(file),
114
+ filePath: file,
115
+ used: false,
116
+ references: [],
117
+ }));
118
+
119
+ // 3. Mark vercel cron routes as used
120
+ const cronPaths = getVercelCronPaths(cwd);
121
+ for (const cronPath of cronPaths) {
122
+ const route = routes.find((r) => r.path === cronPath);
123
+ if (route) {
124
+ route.used = true;
125
+ route.references.push('vercel.json (cron)');
126
+ }
127
+ }
128
+
129
+ // 4. Find all source files to scan
130
+ const extGlob = `**/*{${config.extensions.join(',')}}`;
131
+ const sourceFiles = await fg(extGlob, {
132
+ cwd,
133
+ ignore: [...config.ignore.folders, ...config.ignore.files],
134
+ });
135
+
136
+ // 5. Collect all API paths referenced in codebase
137
+ const allReferencedPaths: Set<string> = new Set();
138
+ const fileReferences: Map<string, string[]> = new Map();
139
+
140
+ for (const file of sourceFiles) {
141
+ const filePath = join(cwd, file);
142
+ try {
143
+ const content = readFileSync(filePath, 'utf-8');
144
+ const paths = extractApiPaths(content);
145
+
146
+ if (paths.length > 0) {
147
+ fileReferences.set(file, paths);
148
+ paths.forEach((p) => allReferencedPaths.add(p));
149
+ }
150
+ } catch {
151
+ // Skip files that can't be read
152
+ }
153
+ }
154
+
155
+ // 6. Mark routes as used if referenced
156
+ const referencedArray = Array.from(allReferencedPaths);
157
+
158
+ for (const route of routes) {
159
+ // Skip ignored routes
160
+ if (shouldIgnoreRoute(route.path, config.ignore.routes)) {
161
+ route.used = true;
162
+ route.references.push('(ignored by config)');
163
+ continue;
164
+ }
165
+
166
+ // Check if already marked (e.g., by vercel cron)
167
+ if (route.used) continue;
168
+
169
+ // Check references
170
+ if (isRouteReferenced(route.path, referencedArray)) {
171
+ route.used = true;
172
+
173
+ // Find which files reference this route
174
+ for (const [file, paths] of fileReferences) {
175
+ if (paths.some((p) => isRouteReferenced(route.path, [p]))) {
176
+ route.references.push(file);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ return {
183
+ total: routes.length,
184
+ used: routes.filter((r) => r.used).length,
185
+ unused: routes.filter((r) => !r.used).length,
186
+ routes,
187
+ };
188
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface IgnoreConfig {
2
+ routes: string[];
3
+ folders: string[];
4
+ files: string[];
5
+ }
6
+
7
+ export interface Config {
8
+ dir: string;
9
+ ignore: IgnoreConfig;
10
+ extensions: string[];
11
+ }
12
+
13
+ export interface ApiRoute {
14
+ /** API path like /api/users */
15
+ path: string;
16
+ /** File path like app/api/users/route.ts */
17
+ filePath: string;
18
+ /** Whether the route is used */
19
+ used: boolean;
20
+ /** Files that reference this route */
21
+ references: string[];
22
+ }
23
+
24
+ export interface ScanResult {
25
+ total: number;
26
+ used: number;
27
+ unused: number;
28
+ routes: ApiRoute[];
29
+ }
30
+
31
+ export interface VercelConfig {
32
+ crons?: Array<{ path: string; schedule: string }>;
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }