@vibecheckai/cli 3.2.0 → 3.2.2

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 (60) hide show
  1. package/bin/runners/lib/agent-firewall/change-packet/builder.js +214 -0
  2. package/bin/runners/lib/agent-firewall/change-packet/schema.json +228 -0
  3. package/bin/runners/lib/agent-firewall/change-packet/store.js +200 -0
  4. package/bin/runners/lib/agent-firewall/claims/claim-types.js +21 -0
  5. package/bin/runners/lib/agent-firewall/claims/extractor.js +214 -0
  6. package/bin/runners/lib/agent-firewall/claims/patterns.js +24 -0
  7. package/bin/runners/lib/agent-firewall/evidence/auth-evidence.js +88 -0
  8. package/bin/runners/lib/agent-firewall/evidence/contract-evidence.js +75 -0
  9. package/bin/runners/lib/agent-firewall/evidence/env-evidence.js +118 -0
  10. package/bin/runners/lib/agent-firewall/evidence/resolver.js +102 -0
  11. package/bin/runners/lib/agent-firewall/evidence/route-evidence.js +142 -0
  12. package/bin/runners/lib/agent-firewall/evidence/side-effect-evidence.js +145 -0
  13. package/bin/runners/lib/agent-firewall/fs-hook/daemon.js +19 -0
  14. package/bin/runners/lib/agent-firewall/fs-hook/installer.js +87 -0
  15. package/bin/runners/lib/agent-firewall/fs-hook/watcher.js +184 -0
  16. package/bin/runners/lib/agent-firewall/git-hook/pre-commit.js +163 -0
  17. package/bin/runners/lib/agent-firewall/ide-extension/cursor.js +107 -0
  18. package/bin/runners/lib/agent-firewall/ide-extension/vscode.js +68 -0
  19. package/bin/runners/lib/agent-firewall/ide-extension/windsurf.js +66 -0
  20. package/bin/runners/lib/agent-firewall/interceptor/base.js +304 -0
  21. package/bin/runners/lib/agent-firewall/interceptor/cursor.js +35 -0
  22. package/bin/runners/lib/agent-firewall/interceptor/vscode.js +35 -0
  23. package/bin/runners/lib/agent-firewall/interceptor/windsurf.js +34 -0
  24. package/bin/runners/lib/agent-firewall/policy/default-policy.json +84 -0
  25. package/bin/runners/lib/agent-firewall/policy/engine.js +72 -0
  26. package/bin/runners/lib/agent-firewall/policy/loader.js +143 -0
  27. package/bin/runners/lib/agent-firewall/policy/rules/auth-drift.js +50 -0
  28. package/bin/runners/lib/agent-firewall/policy/rules/contract-drift.js +50 -0
  29. package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +61 -0
  30. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +50 -0
  31. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +50 -0
  32. package/bin/runners/lib/agent-firewall/policy/rules/scope.js +93 -0
  33. package/bin/runners/lib/agent-firewall/policy/rules/unsafe-side-effect.js +57 -0
  34. package/bin/runners/lib/agent-firewall/policy/schema.json +183 -0
  35. package/bin/runners/lib/agent-firewall/policy/verdict.js +54 -0
  36. package/bin/runners/lib/agent-firewall/truthpack/index.js +67 -0
  37. package/bin/runners/lib/agent-firewall/truthpack/loader.js +116 -0
  38. package/bin/runners/lib/agent-firewall/unblock/planner.js +337 -0
  39. package/bin/runners/lib/analysis-core.js +198 -180
  40. package/bin/runners/lib/analyzers.js +1119 -536
  41. package/bin/runners/lib/cli-output.js +236 -210
  42. package/bin/runners/lib/detectors-v2.js +547 -785
  43. package/bin/runners/lib/fingerprint.js +377 -0
  44. package/bin/runners/lib/route-truth.js +1167 -322
  45. package/bin/runners/lib/scan-output.js +144 -738
  46. package/bin/runners/lib/ship-output-enterprise.js +239 -0
  47. package/bin/runners/lib/terminal-ui.js +188 -770
  48. package/bin/runners/lib/truth.js +1004 -321
  49. package/bin/runners/lib/unified-output.js +162 -158
  50. package/bin/runners/runAgent.js +161 -0
  51. package/bin/runners/runFirewall.js +134 -0
  52. package/bin/runners/runFirewallHook.js +56 -0
  53. package/bin/runners/runScan.js +113 -10
  54. package/bin/runners/runShip.js +7 -8
  55. package/bin/runners/runTruth.js +89 -0
  56. package/mcp-server/agent-firewall-interceptor.js +164 -0
  57. package/mcp-server/index.js +347 -313
  58. package/mcp-server/truth-context.js +131 -90
  59. package/mcp-server/truth-firewall-tools.js +1412 -1045
  60. package/package.json +1 -1
@@ -1,322 +1,1155 @@
1
1
  /**
2
- * Route Truth v1 - JavaScript Runtime
3
- *
4
- * Generates a normalized route map with evidence from:
5
- * - Next.js (App Router + Pages Router)
6
- * - Fastify (shorthand + .route() + register prefixes)
7
- *
8
- * Then implements validate_claim(route_exists) on top of it.
2
+ * Route Truth v1 - JavaScript Runtime (hardened + more accurate)
3
+ *
4
+ * Upgrades vs your version:
5
+ * - Next.js App/Pages routes: AST-based export detection + safer path derivation
6
+ * - Fastify routes: AST-based extraction (get/post/route/register + inline plugins + relative import resolution)
7
+ * - Better canonicalization + prefix joining + safer matching (no weird edge crashes)
8
+ * - Gaps are real (unresolved plugins/modules) instead of silent “false”
9
9
  */
10
10
 
11
- const fs = require('fs');
12
- const path = require('path');
13
- const crypto = require('crypto');
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const crypto = require("crypto");
14
+
15
+ let fg = null;
16
+ try {
17
+ fg = require("fast-glob");
18
+ } catch { /* optional */ }
19
+
20
+ const parser = require("@babel/parser");
21
+ const traverse = require("@babel/traverse").default;
22
+ const t = require("@babel/types");
14
23
 
15
24
  // ============================================================================
16
- // CANONICALIZATION
25
+ // CANONICALIZATION + MATCHING
17
26
  // ============================================================================
18
27
 
19
- /**
20
- * Canonicalize a path to standard format.
21
- */
22
28
  function canonicalizePath(p) {
23
- let s = p.trim();
24
- if (!s.startsWith('/')) s = '/' + s;
25
- s = s.replace(/\/+/g, '/');
26
-
27
- // Convert Next.js dynamic segments
28
- s = s.replace(/\[\[\.{3}([^\]]+)\]\]/g, '*$1?'); // [[...slug]] → *slug?
29
- s = s.replace(/\[\.{3}([^\]]+)\]/g, '*$1'); // [...slug] *slug
30
- s = s.replace(/\[([^\]]+)\]/g, ':$1'); // [id] → :id
31
-
32
- if (s.length > 1) s = s.replace(/\/$/, '');
29
+ let s = String(p || "").trim();
30
+ if (!s) return "/";
31
+ if (!s.startsWith("/")) s = "/" + s;
32
+ s = s.replace(/\/+/g, "/");
33
+
34
+ // Next.js dynamic segments
35
+ s = s.replace(/\[\[\.{3}([^\]]+)\]\]/g, "*$1?"); // [[...slug]] -> *slug?
36
+ s = s.replace(/\[\.{3}([^\]]+)\]/g, "*$1"); // [...slug] -> *slug
37
+ s = s.replace(/\[([^\]]+)\]/g, ":$1"); // [id] -> :id
38
+
39
+ if (s.length > 1) s = s.replace(/\/$/, "");
33
40
  return s;
34
41
  }
35
42
 
36
43
  function canonicalizeMethod(m) {
37
- const u = m.toUpperCase();
38
- if (u === 'ALL' || u === 'ANY') return '*';
39
- return u;
44
+ const u = String(m || "").toUpperCase();
45
+ if (u === "ALL" || u === "ANY" || u === "*" ) return "*";
46
+ return u || "*";
40
47
  }
41
48
 
42
49
  function joinPrefix(prefix, p) {
43
- const cleanPrefix = prefix.replace(/\/$/, '');
44
- const cleanPath = p.startsWith('/') ? p : '/' + p;
45
- return canonicalizePath(cleanPrefix + cleanPath);
50
+ const a = canonicalizePath(prefix || "/");
51
+ const b = canonicalizePath(p || "/");
52
+ if (a === "/") return b;
53
+ if (b === "/") return a;
54
+ return canonicalizePath(a + "/" + b);
46
55
  }
47
56
 
48
- function isParameterizedPath(path) {
49
- return path.includes(':') || path.includes('*');
57
+ function isParameterizedPath(p) {
58
+ const s = canonicalizePath(p);
59
+ return s.includes(":") || s.includes("*");
50
60
  }
51
61
 
62
+ /**
63
+ * Match a pattern against a concrete path.
64
+ * Supported:
65
+ * - :id matches one segment
66
+ * - *slug or *slug? matches the rest of the path (0+ segments)
67
+ */
52
68
  function matchPath(pattern, concrete) {
53
- const patternParts = pattern.split('/');
54
- const concreteParts = concrete.split('/');
55
-
69
+ const pat = canonicalizePath(pattern);
70
+ const con = canonicalizePath(concrete);
71
+
72
+ if (pat === con) return true;
73
+
74
+ const pParts = pat.split("/").filter(Boolean);
75
+ const cParts = con.split("/").filter(Boolean);
76
+
56
77
  let pIdx = 0, cIdx = 0;
57
-
58
- while (pIdx < patternParts.length && cIdx < concreteParts.length) {
59
- const pPart = patternParts[pIdx];
60
- const cPart = concreteParts[cIdx];
61
-
62
- if (pPart.startsWith('*')) return true;
63
- if (pPart.startsWith(':')) { pIdx++; cIdx++; continue; }
64
- if (pPart !== cPart) return false;
78
+
79
+ while (pIdx < pParts.length && cIdx < cParts.length) {
80
+ const pSeg = pParts[pIdx];
81
+ const cSeg = cParts[cIdx];
82
+
83
+ if (pSeg.startsWith("*")) {
84
+ // splat: match remainder (including empty remainder)
85
+ return true;
86
+ }
87
+ if (pSeg.startsWith(":")) {
88
+ pIdx++; cIdx++; continue;
89
+ }
90
+ if (pSeg !== cSeg) return false;
91
+
65
92
  pIdx++; cIdx++;
66
93
  }
67
-
68
- return pIdx === patternParts.length && cIdx === concreteParts.length;
94
+
95
+ // If pattern still has a trailing splat, it matches empty remainder
96
+ if (pIdx === pParts.length - 1 && pParts[pIdx]?.startsWith("*")) return true;
97
+
98
+ return pIdx === pParts.length && cIdx === cParts.length;
69
99
  }
70
100
 
71
101
  function matchMethod(pattern, concrete) {
72
- if (pattern === '*') return true;
73
- return pattern === concrete;
102
+ const p = canonicalizeMethod(pattern);
103
+ const c = canonicalizeMethod(concrete);
104
+ if (p === "*") return true;
105
+ return p === c;
74
106
  }
75
107
 
76
108
  // ============================================================================
77
- // NEXT.JS RESOLVER
109
+ // COMMON HELPERS
78
110
  // ============================================================================
79
111
 
80
- const NEXT_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'];
112
+ const NEXT_HTTP_METHODS = new Set(["GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"]);
81
113
  let evidenceCounter = 0;
82
114
 
115
+ function sha256Short(txt) {
116
+ return crypto.createHash("sha256").update(String(txt || "")).digest("hex").slice(0, 16);
117
+ }
118
+
83
119
  function createEvidence(file, lines, reason, snippet) {
84
120
  evidenceCounter++;
85
121
  return {
86
- id: `ev_${String(evidenceCounter).padStart(4, '0')}`,
122
+ id: `ev_${String(evidenceCounter).padStart(4, "0")}`,
87
123
  file,
88
124
  lines,
89
- snippetHash: `sha256:${crypto.createHash('sha256').update(snippet || '').digest('hex').slice(0, 16)}`,
125
+ snippetHash: `sha256:${sha256Short(snippet || "")}`,
90
126
  reason,
91
127
  };
92
128
  }
93
129
 
94
- function findFiles(dir, include, exclude) {
95
- const files = [];
96
-
130
+ function safeRead(fileAbs) {
131
+ try { return fs.readFileSync(fileAbs, "utf8"); } catch { return null; }
132
+ }
133
+
134
+ function parseFile(code) {
135
+ return parser.parse(code, {
136
+ sourceType: "unambiguous",
137
+ plugins: ["typescript", "jsx"],
138
+ errorRecovery: true,
139
+ ranges: false,
140
+ });
141
+ }
142
+
143
+ function evidenceFromLoc({ fileAbs, fileRel, loc, reason }) {
144
+ if (!loc || !loc.start) return [];
145
+ const code = safeRead(fileAbs);
146
+ if (!code) return [];
147
+ const lines = code.split(/\r?\n/);
148
+ const start = Math.max(1, loc.start.line || 1);
149
+ const end = Math.max(start, loc.end?.line || start);
150
+ const snippet = lines.slice(start - 1, end).join("\n");
151
+ return [createEvidence(fileRel, `${start}-${end}`, reason, snippet)];
152
+ }
153
+
154
+ function findFilesFallback(dirAbs, includeRe, excludeRe) {
155
+ const out = [];
97
156
  function walk(d) {
98
- try {
99
- const entries = fs.readdirSync(d, { withFileTypes: true });
100
- for (const entry of entries) {
101
- const fullPath = path.join(d, entry.name);
102
- if (entry.isDirectory()) {
103
- if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
104
- walk(fullPath);
105
- }
106
- } else if (entry.isFile()) {
107
- if (include.test(entry.name) && (!exclude || !exclude.test(entry.name))) {
108
- files.push(fullPath);
109
- }
110
- }
157
+ let entries;
158
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
159
+ for (const ent of entries) {
160
+ const full = path.join(d, ent.name);
161
+ if (ent.isDirectory()) {
162
+ if (
163
+ ent.name === "node_modules" ||
164
+ ent.name === ".next" ||
165
+ ent.name === "dist" ||
166
+ ent.name === "build" ||
167
+ ent.name === "coverage" ||
168
+ ent.name.startsWith(".")
169
+ ) continue;
170
+ walk(full);
171
+ } else if (ent.isFile()) {
172
+ if (excludeRe && excludeRe.test(ent.name)) continue;
173
+ if (includeRe.test(ent.name)) out.push(full);
111
174
  }
112
- } catch {}
175
+ }
113
176
  }
114
-
115
- walk(dir);
116
- return files;
177
+ walk(dirAbs);
178
+ return out;
117
179
  }
118
180
 
119
- function extractAppRouterMethods(code) {
120
- const methods = [];
121
- const lines = code.split('\n');
122
-
123
- const patterns = [
124
- /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(/,
125
- /export\s+const\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*=/,
126
- ];
127
-
128
- for (let i = 0; i < lines.length; i++) {
129
- for (const pattern of patterns) {
130
- const match = lines[i].match(pattern);
131
- if (match && NEXT_HTTP_METHODS.includes(match[1].toUpperCase())) {
132
- methods.push({
133
- name: match[1].toUpperCase(),
134
- line: i + 1,
135
- snippet: lines[i].trim(),
136
- });
137
- }
181
+ async function globFiles(repoRoot, patterns, ignore) {
182
+ if (fg) {
183
+ return fg(patterns, {
184
+ cwd: repoRoot,
185
+ absolute: true,
186
+ dot: false,
187
+ ignore: ignore || [
188
+ "**/node_modules/**",
189
+ "**/.next/**",
190
+ "**/dist/**",
191
+ "**/build/**",
192
+ "**/coverage/**",
193
+ ],
194
+ });
195
+ }
196
+
197
+ // fallback: only supports the specific Next patterns we use
198
+ const out = [];
199
+ for (const ptn of patterns) {
200
+ // minimal handling: find root dirs from patterns
201
+ if (ptn.includes("app/api") || ptn.includes("src/app/api")) {
202
+ const dir1 = path.join(repoRoot, "app", "api");
203
+ const dir2 = path.join(repoRoot, "src", "app", "api");
204
+ if (fs.existsSync(dir1)) out.push(...findFilesFallback(dir1, /route\.(ts|js)$/));
205
+ if (fs.existsSync(dir2)) out.push(...findFilesFallback(dir2, /route\.(ts|js)$/));
206
+ }
207
+ if (ptn.includes("pages/api") || ptn.includes("src/pages/api")) {
208
+ const dir1 = path.join(repoRoot, "pages", "api");
209
+ const dir2 = path.join(repoRoot, "src", "pages", "api");
210
+ if (fs.existsSync(dir1)) out.push(...findFilesFallback(dir1, /\.(ts|js)$/, /\.d\.ts$/));
211
+ if (fs.existsSync(dir2)) out.push(...findFilesFallback(dir2, /\.(ts|js)$/, /\.d\.ts$/));
138
212
  }
139
213
  }
140
-
214
+ return Array.from(new Set(out));
215
+ }
216
+
217
+ // ============================================================================
218
+ // NEXT.JS RESOLVER (AST)
219
+ // ============================================================================
220
+
221
+ function extractNextAppRouterMethodsAST(ast) {
222
+ const methods = [];
223
+ traverse(ast, {
224
+ ExportNamedDeclaration(p) {
225
+ const decl = p.node.declaration;
226
+
227
+ // export function GET() {}
228
+ if (t.isFunctionDeclaration(decl) && decl.id?.name) {
229
+ const n = decl.id.name.toUpperCase();
230
+ if (NEXT_HTTP_METHODS.has(n)) {
231
+ methods.push({ name: n, loc: decl.loc });
232
+ }
233
+ }
234
+
235
+ // export const GET = () => {}
236
+ if (t.isVariableDeclaration(decl)) {
237
+ for (const d of decl.declarations) {
238
+ if (!t.isIdentifier(d.id)) continue;
239
+ const n = d.id.name.toUpperCase();
240
+ if (NEXT_HTTP_METHODS.has(n)) {
241
+ methods.push({ name: n, loc: d.loc || decl.loc });
242
+ }
243
+ }
244
+ }
245
+ },
246
+ });
141
247
  return methods;
142
248
  }
143
249
 
250
+ function deriveNextAppRoutePath(fileRel) {
251
+ // matches: app/api/**/route.ts|js OR src/app/api/**/route.ts|js
252
+ const m = fileRel.match(/(?:^|\/)(?:src\/)?app\/api\/(.+)\/route\.(ts|js)$/);
253
+ if (!m) return null;
254
+ const sub = m[1];
255
+ return canonicalizePath("/api/" + sub);
256
+ }
257
+
258
+ function deriveNextPagesRoutePath(fileRel) {
259
+ // matches: pages/api/**.ts|js OR src/pages/api/**.ts|js
260
+ const m = fileRel.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(ts|js)$/);
261
+ if (!m) return null;
262
+ let sub = m[1];
263
+ sub = sub.replace(/\/index$/, ""); // /foo/index -> /foo
264
+ return canonicalizePath("/api/" + sub);
265
+ }
266
+
144
267
  async function resolveNextRoutes(repoRoot) {
145
268
  const routes = [];
146
-
147
- // App Router: app/api/**/route.ts|js
148
- const appDirs = ['app', 'src/app'];
149
- for (const appDir of appDirs) {
150
- const apiDir = path.join(repoRoot, appDir, 'api');
151
- if (!fs.existsSync(apiDir)) continue;
152
-
153
- const routeFiles = findFiles(apiDir, /route\.(ts|js)$/);
154
-
155
- for (const file of routeFiles) {
156
- const relPath = path.relative(repoRoot, file).replace(/\\/g, '/');
157
- const apiIdx = relPath.indexOf('/api/');
158
- const sub = relPath.slice(apiIdx + '/api/'.length).replace(/\/route\.(ts|js)$/, '');
159
- const routePath = canonicalizePath('/api/' + sub);
160
-
161
- const code = fs.readFileSync(file, 'utf8');
162
- const methods = extractAppRouterMethods(code);
163
-
164
- if (methods.length === 0) {
165
- routes.push({
166
- method: '*',
167
- path: routePath,
168
- handler: relPath,
169
- framework: 'next',
170
- routerType: 'app',
171
- confidence: 'low',
172
- evidence: [createEvidence(relPath, '1', 'route file with no exports', code.slice(0, 100))],
173
- });
174
- continue;
175
- }
176
-
177
- for (const m of methods) {
178
- routes.push({
179
- method: m.name,
180
- path: routePath,
181
- handler: `${relPath}:${m.line}`,
182
- framework: 'next',
183
- routerType: 'app',
184
- confidence: 'high',
185
- evidence: [createEvidence(relPath, String(m.line), `export ${m.name}`, m.snippet)],
186
- });
187
- }
269
+
270
+ // App Router
271
+ const appFiles = await globFiles(repoRoot, [
272
+ "**/app/api/**/route.@(ts|js)",
273
+ "**/src/app/api/**/route.@(ts|js)",
274
+ ]);
275
+
276
+ for (const fileAbs of appFiles) {
277
+ const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
278
+ const routePath = deriveNextAppRoutePath(fileRel);
279
+ if (!routePath) continue;
280
+
281
+ const code = safeRead(fileAbs);
282
+ if (!code) continue;
283
+
284
+ let ast;
285
+ try { ast = parseFile(code); } catch { continue; }
286
+
287
+ const methods = extractNextAppRouterMethodsAST(ast);
288
+
289
+ if (methods.length === 0) {
290
+ routes.push({
291
+ method: "*",
292
+ path: routePath,
293
+ handler: fileRel,
294
+ framework: "next",
295
+ routerType: "app",
296
+ confidence: "low",
297
+ evidence: [createEvidence(fileRel, "1", "route file with no method exports", code.slice(0, 140))],
298
+ });
299
+ continue;
188
300
  }
189
- }
190
-
191
- // Pages Router: pages/api/**/*.ts|js
192
- const pagesDirs = ['pages', 'src/pages'];
193
- for (const pagesDir of pagesDirs) {
194
- const apiDir = path.join(repoRoot, pagesDir, 'api');
195
- if (!fs.existsSync(apiDir)) continue;
196
-
197
- const apiFiles = findFiles(apiDir, /\.(ts|js)$/, /\.d\.ts$/);
198
-
199
- for (const file of apiFiles) {
200
- const relPath = path.relative(repoRoot, file).replace(/\\/g, '/');
201
- const apiIdx = relPath.indexOf('/api/');
202
- const sub = relPath
203
- .slice(apiIdx + '/api/'.length)
204
- .replace(/\.(ts|js)$/, '')
205
- .replace(/\/index$/, '');
206
-
207
- const routePath = canonicalizePath('/api/' + sub);
208
- const code = fs.readFileSync(file, 'utf8');
209
- const hasDefaultExport = /export\s+default/.test(code);
210
-
301
+
302
+ for (const m of methods) {
211
303
  routes.push({
212
- method: '*',
304
+ method: m.name,
213
305
  path: routePath,
214
- handler: relPath,
215
- framework: 'next',
216
- routerType: 'pages',
217
- confidence: hasDefaultExport ? 'med' : 'low',
218
- evidence: [createEvidence(relPath, '1', 'Pages API route', code.slice(0, 100))],
306
+ handler: fileRel,
307
+ framework: "next",
308
+ routerType: "app",
309
+ confidence: "high",
310
+ evidence: evidenceFromLoc({ fileAbs, fileRel, loc: m.loc, reason: `Next app router export ${m.name}` }),
219
311
  });
220
312
  }
221
313
  }
222
-
314
+
315
+ // Pages Router
316
+ const pagesFiles = await globFiles(repoRoot, [
317
+ "**/pages/api/**/*.@(ts|js)",
318
+ "**/src/pages/api/**/*.@(ts|js)",
319
+ ]);
320
+
321
+ for (const fileAbs of pagesFiles) {
322
+ const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
323
+ if (fileRel.endsWith(".d.ts")) continue;
324
+
325
+ const routePath = deriveNextPagesRoutePath(fileRel);
326
+ if (!routePath) continue;
327
+
328
+ const code = safeRead(fileAbs);
329
+ if (!code) continue;
330
+
331
+ const hasDefaultExport = /\bexport\s+default\b/.test(code);
332
+
333
+ routes.push({
334
+ method: "*",
335
+ path: routePath,
336
+ handler: fileRel,
337
+ framework: "next",
338
+ routerType: "pages",
339
+ confidence: hasDefaultExport ? "med" : "low",
340
+ evidence: [createEvidence(fileRel, "1", "Next pages API route", code.slice(0, 140))],
341
+ });
342
+ }
343
+
223
344
  return routes;
224
345
  }
225
346
 
226
347
  // ============================================================================
227
- // FASTIFY RESOLVER (Simplified - regex based)
348
+ // FASTIFY RESOLVER (AST, follows register prefixes + relative plugin modules)
228
349
  // ============================================================================
229
350
 
230
- async function resolveFastifyRoutes(repoRoot) {
231
- const routes = [];
232
- const gaps = [];
233
-
234
- const entryPoints = [
235
- 'src/server.ts', 'src/server.js', 'src/index.ts', 'src/index.js',
236
- 'server.ts', 'server.js', 'apps/api/src/server.ts', 'apps/api/src/index.ts',
237
- ];
238
-
239
- const fastifyMethods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'all'];
240
-
241
- // Find source files
242
- const srcDirs = ['src', 'apps/api/src', 'server'];
243
- const files = [];
244
-
245
- for (const srcDir of srcDirs) {
246
- const fullDir = path.join(repoRoot, srcDir);
247
- if (fs.existsSync(fullDir)) {
248
- files.push(...findFiles(fullDir, /\.(ts|js)$/, /\.d\.ts$/));
351
+ const FASTIFY_METHODS = new Set(["get","post","put","patch","delete","options","head","all"]);
352
+
353
+ function existsFile(p) {
354
+ try { return fs.statSync(p).isFile(); } catch { return false; }
355
+ }
356
+
357
+ function existsDir(p) {
358
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
359
+ }
360
+
361
+ /**
362
+ * Find project root by walking up from a file until we find package.json
363
+ */
364
+ function findProjectRoot(fromFileAbs) {
365
+ let dir = path.dirname(fromFileAbs);
366
+ const root = path.parse(dir).root;
367
+ while (dir !== root) {
368
+ if (existsFile(path.join(dir, "package.json"))) return dir;
369
+ const parent = path.dirname(dir);
370
+ if (parent === dir) break;
371
+ dir = parent;
372
+ }
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Load and cache tsconfig.json paths for a project
378
+ */
379
+ const tsconfigCache = new Map();
380
+
381
+ function loadTsConfigPaths(projectRoot) {
382
+ if (!projectRoot) return null;
383
+ if (tsconfigCache.has(projectRoot)) return tsconfigCache.get(projectRoot);
384
+
385
+ const tsconfigPath = path.join(projectRoot, "tsconfig.json");
386
+ if (!existsFile(tsconfigPath)) {
387
+ tsconfigCache.set(projectRoot, null);
388
+ return null;
389
+ }
390
+
391
+ try {
392
+ const raw = fs.readFileSync(tsconfigPath, "utf8");
393
+ // Remove comments (// and /* */) for JSON parsing
394
+ const cleaned = raw
395
+ .replace(/\/\/.*$/gm, "")
396
+ .replace(/\/\*[\s\S]*?\*\//g, "");
397
+ const tsconfig = JSON.parse(cleaned);
398
+
399
+ const paths = tsconfig?.compilerOptions?.paths || {};
400
+ const baseUrl = tsconfig?.compilerOptions?.baseUrl || ".";
401
+ const baseDir = path.resolve(projectRoot, baseUrl);
402
+
403
+ const result = { paths, baseDir, projectRoot };
404
+ tsconfigCache.set(projectRoot, result);
405
+ return result;
406
+ } catch {
407
+ tsconfigCache.set(projectRoot, null);
408
+ return null;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Resolve a module specifier using TypeScript path mappings
414
+ */
415
+ function resolveWithTsConfigPaths(spec, tsConfig) {
416
+ if (!tsConfig || !tsConfig.paths) return null;
417
+
418
+ const { paths, baseDir } = tsConfig;
419
+
420
+ for (const [pattern, targets] of Object.entries(paths)) {
421
+ // Handle exact match: "@vibecheck/core" -> ["../../packages/core/dist"]
422
+ if (pattern === spec) {
423
+ for (const target of targets) {
424
+ const resolved = path.resolve(baseDir, target.replace(/\/\*$/, ""));
425
+ const candidates = [
426
+ resolved,
427
+ resolved + ".ts",
428
+ resolved + ".js",
429
+ path.join(resolved, "index.ts"),
430
+ path.join(resolved, "index.js"),
431
+ ];
432
+ for (const c of candidates) if (existsFile(c)) return c;
433
+ }
434
+ }
435
+
436
+ // Handle wildcard pattern: "@/*" -> ["./src/*"]
437
+ if (pattern.endsWith("/*")) {
438
+ const prefix = pattern.slice(0, -2);
439
+ if (spec.startsWith(prefix + "/")) {
440
+ const rest = spec.slice(prefix.length + 1);
441
+ for (const target of targets) {
442
+ const targetBase = target.replace(/\/\*$/, "");
443
+ const resolved = path.resolve(baseDir, targetBase, rest);
444
+ const candidates = [
445
+ resolved,
446
+ resolved + ".ts",
447
+ resolved + ".js",
448
+ path.join(resolved, "index.ts"),
449
+ path.join(resolved, "index.js"),
450
+ ];
451
+ for (const c of candidates) if (existsFile(c)) return c;
452
+ }
453
+ }
249
454
  }
250
455
  }
251
-
252
- // Patterns to detect routes
253
- const patterns = [
254
- // fastify.get('/path', handler)
255
- /(?:fastify|app|server)\.(get|post|put|patch|delete|options|head|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
256
- // router.get('/path', handler)
257
- /router\.(get|post|put|patch|delete|options|head|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
258
- // .route({ method: 'GET', url: '/path' })
259
- /\.route\s*\(\s*\{[^}]*method:\s*['"`]([^'"`]+)['"`][^}]*url:\s*['"`]([^'"`]+)['"`]/gi,
260
- /\.route\s*\(\s*\{[^}]*url:\s*['"`]([^'"`]+)['"`][^}]*method:\s*['"`]([^'"`]+)['"`]/gi,
456
+
457
+ return null;
458
+ }
459
+
460
+ /**
461
+ * Parse package.json and find the main entrypoint
462
+ */
463
+ function getPackageEntrypoint(pkgJsonPath) {
464
+ try {
465
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
466
+ const pkgDir = path.dirname(pkgJsonPath);
467
+
468
+ // Priority: exports["."] > main > index.js
469
+ // Handle exports field (modern packages)
470
+ if (pkgJson.exports) {
471
+ const exp = pkgJson.exports;
472
+
473
+ // exports: "./lib/index.js" (string shorthand)
474
+ if (typeof exp === "string") {
475
+ const resolved = path.resolve(pkgDir, exp);
476
+ if (existsFile(resolved)) return resolved;
477
+ }
478
+
479
+ // exports: { ".": "./lib/index.js" } or { ".": { "require": "...", "import": "..." } }
480
+ if (typeof exp === "object" && exp["."]) {
481
+ const dotExport = exp["."];
482
+
483
+ if (typeof dotExport === "string") {
484
+ const resolved = path.resolve(pkgDir, dotExport);
485
+ if (existsFile(resolved)) return resolved;
486
+ }
487
+
488
+ // Conditional exports - prefer require for CommonJS, then import, then default
489
+ if (typeof dotExport === "object") {
490
+ const conditions = ["require", "node", "import", "default"];
491
+ for (const cond of conditions) {
492
+ if (dotExport[cond]) {
493
+ const val = dotExport[cond];
494
+ const target = typeof val === "string" ? val : val?.default;
495
+ if (target) {
496
+ const resolved = path.resolve(pkgDir, target);
497
+ if (existsFile(resolved)) return resolved;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ // Fallback to main field
506
+ if (pkgJson.main) {
507
+ const resolved = path.resolve(pkgDir, pkgJson.main);
508
+ if (existsFile(resolved)) return resolved;
509
+ }
510
+
511
+ // Final fallback: index.js
512
+ const indexJs = path.join(pkgDir, "index.js");
513
+ if (existsFile(indexJs)) return indexJs;
514
+
515
+ return null;
516
+ } catch {
517
+ return null;
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Resolve a package specifier from node_modules
523
+ * Handles: "fastify", "@fastify/cors", "@vibecheck/core"
524
+ */
525
+ function resolveFromNodeModules(spec, projectRoot) {
526
+ if (!projectRoot || !spec) return null;
527
+
528
+ // Don't resolve built-in Node.js modules
529
+ const builtins = new Set([
530
+ "fs", "path", "http", "https", "url", "crypto", "os", "util", "stream",
531
+ "events", "buffer", "querystring", "child_process", "cluster", "dgram",
532
+ "dns", "net", "readline", "tls", "tty", "zlib", "assert", "async_hooks",
533
+ "perf_hooks", "v8", "vm", "worker_threads", "module", "process"
534
+ ]);
535
+
536
+ const pkgName = spec.startsWith("@")
537
+ ? spec.split("/").slice(0, 2).join("/") // @scope/name
538
+ : spec.split("/")[0]; // name
539
+
540
+ if (builtins.has(pkgName)) return null;
541
+
542
+ // Walk up directory tree looking for node_modules
543
+ let searchDir = projectRoot;
544
+ const root = path.parse(searchDir).root;
545
+
546
+ while (searchDir !== root) {
547
+ const nodeModulesDir = path.join(searchDir, "node_modules");
548
+
549
+ if (existsDir(nodeModulesDir)) {
550
+ const pkgDir = path.join(nodeModulesDir, pkgName);
551
+
552
+ if (existsDir(pkgDir)) {
553
+ const pkgJsonPath = path.join(pkgDir, "package.json");
554
+
555
+ if (existsFile(pkgJsonPath)) {
556
+ // Check if spec has a subpath: "@fastify/cors/lib/foo"
557
+ const subpath = spec.slice(pkgName.length);
558
+
559
+ if (subpath && subpath !== "/") {
560
+ // Resolve subpath within the package
561
+ const subpathResolved = path.join(pkgDir, subpath);
562
+ const candidates = [
563
+ subpathResolved,
564
+ subpathResolved + ".js",
565
+ subpathResolved + ".ts",
566
+ path.join(subpathResolved, "index.js"),
567
+ path.join(subpathResolved, "index.ts"),
568
+ ];
569
+ for (const c of candidates) if (existsFile(c)) return c;
570
+ }
571
+
572
+ // Resolve main entrypoint
573
+ const entry = getPackageEntrypoint(pkgJsonPath);
574
+ if (entry) return entry;
575
+ }
576
+ }
577
+ }
578
+
579
+ const parent = path.dirname(searchDir);
580
+ if (parent === searchDir) break;
581
+ searchDir = parent;
582
+ }
583
+
584
+ return null;
585
+ }
586
+
587
+ /**
588
+ * Check if a package is a "non-route" Fastify plugin
589
+ * These plugins add functionality but don't define routes themselves
590
+ */
591
+ function isNonRoutePlugin(spec) {
592
+ const nonRoutePlugins = new Set([
593
+ // @fastify/* scoped plugins
594
+ "@fastify/cors",
595
+ "@fastify/helmet",
596
+ "@fastify/compress",
597
+ "@fastify/cookie",
598
+ "@fastify/secure-session",
599
+ "@fastify/session",
600
+ "@fastify/rate-limit",
601
+ "@fastify/jwt",
602
+ "@fastify/auth",
603
+ "@fastify/bearer-auth",
604
+ "@fastify/basic-auth",
605
+ "@fastify/multipart",
606
+ "@fastify/formbody",
607
+ "@fastify/static",
608
+ "@fastify/view",
609
+ "@fastify/sensible",
610
+ "@fastify/env",
611
+ "@fastify/accepts",
612
+ "@fastify/caching",
613
+ "@fastify/etag",
614
+ "@fastify/circuit-breaker",
615
+ "@fastify/response-validation",
616
+ "@fastify/request-context",
617
+ "@fastify/under-pressure",
618
+ "@fastify/middie",
619
+ "@fastify/express",
620
+ "@fastify/http-proxy",
621
+ "@fastify/reply-from",
622
+ "@fastify/websocket",
623
+ "@fastify/type-provider-json-schema-to-ts",
624
+ "@fastify/type-provider-typebox",
625
+ "@fastify/type-provider-zod",
626
+ "@fastify/mongodb",
627
+ "@fastify/postgres",
628
+ "@fastify/mysql",
629
+ "@fastify/redis",
630
+ "@fastify/leveldb",
631
+ "@fastify/elasticsearch",
632
+ "@fastify/metrics",
633
+ "@fastify/request-id",
634
+ // Legacy fastify-* plugins
635
+ "fastify-plugin",
636
+ "fastify-cors",
637
+ "fastify-helmet",
638
+ "fastify-compress",
639
+ "fastify-cookie",
640
+ "fastify-session",
641
+ "fastify-rate-limit",
642
+ "fastify-jwt",
643
+ "fastify-auth",
644
+ "fastify-sensible",
645
+ "fastify-multipart",
646
+ "fastify-formbody",
647
+ "fastify-static",
648
+ "fastify-websocket",
649
+ ]);
650
+
651
+ const pkgName = spec.startsWith("@")
652
+ ? spec.split("/").slice(0, 2).join("/")
653
+ : spec.split("/")[0];
654
+
655
+ return nonRoutePlugins.has(pkgName);
656
+ }
657
+
658
+ /**
659
+ * Check if this is @fastify/autoload which needs special directory handling
660
+ */
661
+ function isAutoloadPlugin(spec) {
662
+ return spec === "@fastify/autoload" || spec === "fastify-autoload";
663
+ }
664
+
665
+ function resolveRelativeModule(fromFileAbs, spec) {
666
+ if (!spec || (!spec.startsWith("./") && !spec.startsWith("../"))) return null;
667
+ const base = path.resolve(path.dirname(fromFileAbs), spec);
668
+ const candidates = [
669
+ base,
670
+ base + ".ts",
671
+ base + ".js",
672
+ path.join(base, "index.ts"),
673
+ path.join(base, "index.js"),
261
674
  ];
262
-
263
- // Track prefixes from register calls
264
- const prefixMap = new Map(); // file → prefix
265
-
266
- for (const file of files) {
267
- try {
268
- const code = fs.readFileSync(file, 'utf8');
269
- const relPath = path.relative(repoRoot, file).replace(/\\/g, '/');
270
- const lines = code.split('\n');
271
-
272
- // Detect prefix from register calls
273
- const registerPattern = /\.register\s*\([^,]+,\s*\{[^}]*prefix:\s*['"`]([^'"`]+)['"`]/g;
274
- let match;
275
- while ((match = registerPattern.exec(code)) !== null) {
276
- prefixMap.set(relPath, match[1]);
675
+ for (const c of candidates) if (existsFile(c)) return c;
676
+ return null;
677
+ }
678
+
679
+ /**
680
+ * Resolve any module specifier (relative, absolute, package, or TS paths)
681
+ * Returns: { resolved: string | null, isNonRoute: boolean, reason: string }
682
+ */
683
+ function resolveModuleSpec(fromFileAbs, spec) {
684
+ if (!spec) return { resolved: null, isNonRoute: false, reason: "empty spec" };
685
+
686
+ // 1. Relative imports
687
+ if (spec.startsWith("./") || spec.startsWith("../")) {
688
+ const resolved = resolveRelativeModule(fromFileAbs, spec);
689
+ return {
690
+ resolved,
691
+ isNonRoute: false,
692
+ reason: resolved ? "relative import" : "relative module not found"
693
+ };
694
+ }
695
+
696
+ // 2. Check if it's a non-route plugin (skip scanning)
697
+ if (isNonRoutePlugin(spec)) {
698
+ return {
699
+ resolved: null,
700
+ isNonRoute: true,
701
+ reason: `non-route plugin: ${spec}`
702
+ };
703
+ }
704
+
705
+ const projectRoot = findProjectRoot(fromFileAbs);
706
+
707
+ // 3. TypeScript path mappings
708
+ if (projectRoot) {
709
+ const tsConfig = loadTsConfigPaths(projectRoot);
710
+ if (tsConfig) {
711
+ const resolved = resolveWithTsConfigPaths(spec, tsConfig);
712
+ if (resolved) {
713
+ return { resolved, isNonRoute: false, reason: "tsconfig paths" };
714
+ }
715
+ }
716
+ }
717
+
718
+ // 4. Node modules resolution
719
+ if (projectRoot) {
720
+ const resolved = resolveFromNodeModules(spec, projectRoot);
721
+ if (resolved) {
722
+ return { resolved, isNonRoute: false, reason: "node_modules" };
723
+ }
724
+ }
725
+
726
+ return {
727
+ resolved: null,
728
+ isNonRoute: false,
729
+ reason: `unresolved package: ${spec}`
730
+ };
731
+ }
732
+
733
+ function extractStringLiteral(node) {
734
+ return t.isStringLiteral(node) ? node.value : null;
735
+ }
736
+
737
+ function extractPrefixFromOpts(node) {
738
+ if (!t.isObjectExpression(node)) return null;
739
+ for (const p of node.properties) {
740
+ if (!t.isObjectProperty(p)) continue;
741
+ const key =
742
+ t.isIdentifier(p.key) ? p.key.name :
743
+ t.isStringLiteral(p.key) ? p.key.value :
744
+ null;
745
+ if (key === "prefix" && t.isStringLiteral(p.value)) return p.value.value;
746
+ }
747
+ return null;
748
+ }
749
+
750
+ function extractRouteObject(objExpr) {
751
+ let url = null;
752
+ let methods = [];
753
+ let hasHandler = false;
754
+
755
+ for (const p of objExpr.properties) {
756
+ if (!t.isObjectProperty(p)) continue;
757
+
758
+ const key =
759
+ t.isIdentifier(p.key) ? p.key.name :
760
+ t.isStringLiteral(p.key) ? p.key.value :
761
+ null;
762
+ if (!key) continue;
763
+
764
+ if (key === "url" && t.isStringLiteral(p.value)) url = p.value.value;
765
+
766
+ if (key === "method") {
767
+ if (t.isStringLiteral(p.value)) methods = [p.value.value];
768
+ if (t.isArrayExpression(p.value)) {
769
+ methods = p.value.elements.filter(e => t.isStringLiteral(e)).map(e => e.value);
277
770
  }
278
-
279
- // Extract routes
280
- for (const pattern of patterns) {
281
- pattern.lastIndex = 0;
282
- while ((match = pattern.exec(code)) !== null) {
283
- let method, routePath;
284
-
285
- if (match[0].includes('.route')) {
286
- // Handle .route() pattern - order varies
287
- if (match[0].indexOf('method') < match[0].indexOf('url')) {
288
- method = match[1];
289
- routePath = match[2];
290
- } else {
291
- routePath = match[1];
292
- method = match[2];
771
+ }
772
+
773
+ if (key === "handler") hasHandler = true;
774
+ }
775
+
776
+ return { url, methods, hasHandler };
777
+ }
778
+
779
+ function detectFastifyEntry(repoRoot) {
780
+ const candidates = [
781
+ "src/server.ts","src/server.js",
782
+ "server.ts","server.js",
783
+ "src/index.ts","src/index.js",
784
+ "index.ts","index.js",
785
+ "apps/api/src/server.ts",
786
+ "apps/api/src/index.ts",
787
+ ];
788
+ for (const rel of candidates) {
789
+ const abs = path.join(repoRoot, rel);
790
+ if (existsFile(abs)) return rel;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ function resolveFastifyRoutesFromEntry(repoRoot, entryAbs) {
796
+ const seen = new Set();
797
+ const routes = [];
798
+ const gaps = [];
799
+
800
+ function scanFile(fileAbs, prefix) {
801
+ if (!fileAbs || seen.has(fileAbs)) return;
802
+ seen.add(fileAbs);
803
+
804
+ const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
805
+ const code = safeRead(fileAbs);
806
+ if (!code) return;
807
+
808
+ let ast;
809
+ try { ast = parseFile(code); } catch { return; }
810
+
811
+ // fastify instance identifiers
812
+ const fastifyNames = new Set(["fastify", "app", "server"]);
813
+
814
+ traverse(ast, {
815
+ VariableDeclarator(p) {
816
+ if (!t.isIdentifier(p.node.id)) return;
817
+ const id = p.node.id.name;
818
+ const init = p.node.init;
819
+ if (!init) return;
820
+ if (t.isCallExpression(init) && t.isIdentifier(init.callee)) {
821
+ const cal = init.callee.name;
822
+ if (cal === "Fastify" || cal === "fastify") fastifyNames.add(id);
823
+ }
824
+ },
825
+ });
826
+
827
+ function resolveImportSpecForLocal(localName) {
828
+ let spec = null;
829
+
830
+ traverse(ast, {
831
+ ImportDeclaration(ip) {
832
+ for (const s of ip.node.specifiers) {
833
+ if (
834
+ (t.isImportDefaultSpecifier(s) || t.isImportSpecifier(s)) &&
835
+ s.local.name === localName
836
+ ) {
837
+ spec = ip.node.source.value;
293
838
  }
294
- } else {
295
- method = match[1];
296
- routePath = match[2];
297
839
  }
298
-
299
- const prefix = prefixMap.get(relPath) || '';
300
- const fullPath = joinPrefix(prefix, routePath);
301
- const lineNum = code.substring(0, match.index).split('\n').length;
302
- const snippet = lines[lineNum - 1] || '';
303
-
840
+ },
841
+ VariableDeclarator(vp) {
842
+ if (!t.isIdentifier(vp.node.id) || vp.node.id.name !== localName) return;
843
+ const init = vp.node.init;
844
+ if (!t.isCallExpression(init)) return;
845
+ if (!t.isIdentifier(init.callee) || init.callee.name !== "require") return;
846
+ const a0 = init.arguments[0];
847
+ if (t.isStringLiteral(a0)) spec = a0.value;
848
+ },
849
+ });
850
+
851
+ return spec;
852
+ }
853
+
854
+ traverse(ast, {
855
+ CallExpression(p) {
856
+ const callee = p.node.callee;
857
+ if (!t.isMemberExpression(callee)) return;
858
+ if (!t.isIdentifier(callee.object) || !t.isIdentifier(callee.property)) return;
859
+
860
+ const obj = callee.object.name;
861
+ const prop = callee.property.name;
862
+
863
+ if (!fastifyNames.has(obj)) return;
864
+
865
+ // fastify.get('/x', ...)
866
+ if (FASTIFY_METHODS.has(prop)) {
867
+ const routeStr = extractStringLiteral(p.node.arguments[0]);
868
+ if (!routeStr) return;
869
+
304
870
  routes.push({
305
- method: canonicalizeMethod(method),
306
- path: fullPath,
307
- handler: `${relPath}:${lineNum}`,
308
- framework: 'fastify',
309
- confidence: 'med',
310
- evidence: [createEvidence(relPath, String(lineNum), `fastify.${method}('${routePath}')`, snippet)],
871
+ method: canonicalizeMethod(prop),
872
+ path: joinPrefix(prefix, routeStr),
873
+ handler: fileRel,
874
+ framework: "fastify",
875
+ confidence: "med",
876
+ evidence: evidenceFromLoc({
877
+ fileAbs,
878
+ fileRel,
879
+ loc: p.node.loc,
880
+ reason: `Fastify ${prop.toUpperCase()}("${routeStr}")`,
881
+ }),
311
882
  });
883
+ return;
884
+ }
885
+
886
+ // fastify.route({ method, url, handler })
887
+ if (prop === "route") {
888
+ const arg0 = p.node.arguments[0];
889
+ if (!t.isObjectExpression(arg0)) return;
890
+
891
+ const r = extractRouteObject(arg0);
892
+ if (!r.url) return;
893
+
894
+ const fullPath = joinPrefix(prefix, r.url);
895
+ const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
896
+
897
+ for (const m of ms) {
898
+ routes.push({
899
+ method: m,
900
+ path: fullPath,
901
+ handler: fileRel,
902
+ framework: "fastify",
903
+ confidence: r.hasHandler ? "med" : "low",
904
+ evidence: evidenceFromLoc({
905
+ fileAbs,
906
+ fileRel,
907
+ loc: p.node.loc,
908
+ reason: `Fastify.route({ url: "${r.url}" })`,
909
+ }),
910
+ });
911
+ }
912
+ return;
913
+ }
914
+
915
+ // fastify.register(plugin, { prefix })
916
+ if (prop === "register") {
917
+ const pluginArg = p.node.arguments[0];
918
+ const optsArg = p.node.arguments[1];
919
+ const childPrefixRaw = extractPrefixFromOpts(optsArg);
920
+ const childPrefix = childPrefixRaw ? joinPrefix(prefix, childPrefixRaw) : prefix;
921
+
922
+ // inline plugin: fastify.register((f, opts) => { f.get(...) }, { prefix })
923
+ if (t.isFunctionExpression(pluginArg) || t.isArrowFunctionExpression(pluginArg)) {
924
+ const param0 = pluginArg.params[0];
925
+ const innerName = t.isIdentifier(param0) ? param0.name : "fastify";
926
+
927
+ traverse(
928
+ pluginArg.body,
929
+ {
930
+ CallExpression(pp) {
931
+ const c = pp.node.callee;
932
+ if (!t.isMemberExpression(c)) return;
933
+ if (!t.isIdentifier(c.object) || !t.isIdentifier(c.property)) return;
934
+ if (c.object.name !== innerName) return;
935
+
936
+ const pr = c.property.name;
937
+
938
+ if (FASTIFY_METHODS.has(pr)) {
939
+ const rs = extractStringLiteral(pp.node.arguments[0]);
940
+ if (!rs) return;
941
+
942
+ routes.push({
943
+ method: canonicalizeMethod(pr),
944
+ path: joinPrefix(childPrefix, rs),
945
+ handler: fileRel,
946
+ framework: "fastify",
947
+ confidence: "med",
948
+ evidence: evidenceFromLoc({
949
+ fileAbs,
950
+ fileRel,
951
+ loc: pp.node.loc,
952
+ reason: `Fastify plugin ${pr.toUpperCase()}("${rs}") prefix="${childPrefixRaw || ""}"`,
953
+ }),
954
+ });
955
+ }
956
+
957
+ if (pr === "route") {
958
+ const a0 = pp.node.arguments[0];
959
+ if (!t.isObjectExpression(a0)) return;
960
+ const r = extractRouteObject(a0);
961
+ if (!r.url) return;
962
+
963
+ const fullPath = joinPrefix(childPrefix, r.url);
964
+ const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
965
+
966
+ for (const m of ms) {
967
+ routes.push({
968
+ method: m,
969
+ path: fullPath,
970
+ handler: fileRel,
971
+ framework: "fastify",
972
+ confidence: r.hasHandler ? "med" : "low",
973
+ evidence: evidenceFromLoc({
974
+ fileAbs,
975
+ fileRel,
976
+ loc: pp.node.loc,
977
+ reason: `Fastify plugin route("${r.url}") prefix="${childPrefixRaw || ""}"`,
978
+ }),
979
+ });
980
+ }
981
+ }
982
+ },
983
+ },
984
+ p.scope,
985
+ p
986
+ );
987
+
988
+ return;
989
+ }
990
+
991
+ // imported plugin identifier: resolve module (relative, package, or TS paths)
992
+ if (t.isIdentifier(pluginArg)) {
993
+ const localName = pluginArg.name;
994
+ const spec = resolveImportSpecForLocal(localName);
995
+
996
+ if (!spec) {
997
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, name: localName });
998
+ return;
999
+ }
1000
+
1001
+ // Handle @fastify/autoload: scan the specified directory
1002
+ if (isAutoloadPlugin(spec)) {
1003
+ const autoloadDir = extractAutoloadDir(optsArg, fileAbs);
1004
+ if (autoloadDir && existsDir(autoloadDir)) {
1005
+ scanAutoloadDir(autoloadDir, childPrefix);
1006
+ }
1007
+ return;
1008
+ }
1009
+
1010
+ const { resolved, isNonRoute, reason } = resolveModuleSpec(fileAbs, spec);
1011
+
1012
+ // Skip non-route plugins silently (they don't add routes)
1013
+ if (isNonRoute) {
1014
+ return;
1015
+ }
1016
+
1017
+ if (!resolved) {
1018
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, spec, reason });
1019
+ return;
1020
+ }
1021
+
1022
+ scanFile(resolved, childPrefix);
1023
+ }
1024
+
1025
+ // Direct require/import: fastify.register(require('./routes'))
1026
+ if (t.isCallExpression(pluginArg)) {
1027
+ const callee = pluginArg.callee;
1028
+ if (t.isIdentifier(callee) && callee.name === "require") {
1029
+ const reqArg = pluginArg.arguments[0];
1030
+ if (t.isStringLiteral(reqArg)) {
1031
+ const spec = reqArg.value;
1032
+
1033
+ // Handle @fastify/autoload via require
1034
+ if (isAutoloadPlugin(spec)) {
1035
+ const autoloadDir = extractAutoloadDir(optsArg, fileAbs);
1036
+ if (autoloadDir && existsDir(autoloadDir)) {
1037
+ scanAutoloadDir(autoloadDir, childPrefix);
1038
+ }
1039
+ return;
1040
+ }
1041
+
1042
+ const { resolved, isNonRoute, reason } = resolveModuleSpec(fileAbs, spec);
1043
+
1044
+ if (isNonRoute) return;
1045
+
1046
+ if (!resolved) {
1047
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, spec, reason });
1048
+ return;
1049
+ }
1050
+
1051
+ scanFile(resolved, childPrefix);
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ },
1057
+ });
1058
+ }
1059
+
1060
+ /**
1061
+ * Extract the 'dir' option from autoload config
1062
+ * Handles: { dir: path.join(__dirname, 'routes') } or { dir: './routes' }
1063
+ */
1064
+ function extractAutoloadDir(optsNode, fromFileAbs) {
1065
+ if (!t.isObjectExpression(optsNode)) return null;
1066
+
1067
+ for (const prop of optsNode.properties) {
1068
+ if (!t.isObjectProperty(prop)) continue;
1069
+
1070
+ const key = t.isIdentifier(prop.key) ? prop.key.name :
1071
+ t.isStringLiteral(prop.key) ? prop.key.value : null;
1072
+
1073
+ if (key !== "dir") continue;
1074
+
1075
+ // Simple string: { dir: './routes' }
1076
+ if (t.isStringLiteral(prop.value)) {
1077
+ return path.resolve(path.dirname(fromFileAbs), prop.value.value);
1078
+ }
1079
+
1080
+ // path.join(__dirname, 'routes')
1081
+ if (t.isCallExpression(prop.value)) {
1082
+ const callee = prop.value.callee;
1083
+ if (t.isMemberExpression(callee) &&
1084
+ t.isIdentifier(callee.object) && callee.object.name === "path" &&
1085
+ t.isIdentifier(callee.property) && callee.property.name === "join") {
1086
+ const args = prop.value.arguments;
1087
+ // path.join(__dirname, 'subdir')
1088
+ if (args.length >= 2 && t.isIdentifier(args[0]) && args[0].name === "__dirname") {
1089
+ const parts = args.slice(1)
1090
+ .filter(a => t.isStringLiteral(a))
1091
+ .map(a => a.value);
1092
+ if (parts.length > 0) {
1093
+ return path.resolve(path.dirname(fromFileAbs), ...parts);
1094
+ }
1095
+ }
312
1096
  }
313
1097
  }
314
- } catch {}
1098
+
1099
+ // Template literal or identifier - can't resolve statically
1100
+ return null;
1101
+ }
1102
+
1103
+ return null;
1104
+ }
1105
+
1106
+ /**
1107
+ * Scan a directory for route files (used by @fastify/autoload)
1108
+ */
1109
+ function scanAutoloadDir(dirAbs, prefix) {
1110
+ if (!existsDir(dirAbs)) return;
1111
+
1112
+ let entries;
1113
+ try {
1114
+ entries = fs.readdirSync(dirAbs, { withFileTypes: true });
1115
+ } catch {
1116
+ return;
1117
+ }
1118
+
1119
+ for (const ent of entries) {
1120
+ const fullPath = path.join(dirAbs, ent.name);
1121
+
1122
+ if (ent.isDirectory()) {
1123
+ // Subdirectory: recurse with directory name as prefix segment
1124
+ // @fastify/autoload uses directory names as route prefixes
1125
+ const subPrefix = joinPrefix(prefix, "/" + ent.name);
1126
+ scanAutoloadDir(fullPath, subPrefix);
1127
+ } else if (ent.isFile() && /\.(ts|js)$/.test(ent.name) && !ent.name.endsWith(".d.ts")) {
1128
+ // Skip index files for prefix (they define routes at current prefix level)
1129
+ const isIndex = /^index\.(ts|js)$/.test(ent.name);
1130
+ const filePrefix = isIndex ? prefix : joinPrefix(prefix, "/" + ent.name.replace(/\.(ts|js)$/, ""));
1131
+
1132
+ scanFile(fullPath, filePrefix);
1133
+ }
1134
+ }
315
1135
  }
316
-
1136
+
1137
+ scanFile(entryAbs, "/");
317
1138
  return { routes, gaps };
318
1139
  }
319
1140
 
1141
+ async function resolveFastifyRoutes(repoRoot, entryRel = null) {
1142
+ const entry = entryRel || detectFastifyEntry(repoRoot);
1143
+ if (!entry) return { routes: [], gaps: [{ kind: "fastify_entry_missing", file: null }] };
1144
+
1145
+ const entryAbs = path.isAbsolute(entry) ? entry : path.join(repoRoot, entry);
1146
+ if (!existsFile(entryAbs)) {
1147
+ return { routes: [], gaps: [{ kind: "fastify_entry_missing", file: entry }] };
1148
+ }
1149
+
1150
+ return resolveFastifyRoutesFromEntry(repoRoot, entryAbs);
1151
+ }
1152
+
320
1153
  // ============================================================================
321
1154
  // ROUTE INDEX
322
1155
  // ============================================================================
@@ -329,87 +1162,88 @@ class RouteIndex {
329
1162
  this.parameterized = [];
330
1163
  this.gaps = [];
331
1164
  }
332
-
333
- async build(repoRoot) {
334
- // Resolve Next.js routes
1165
+
1166
+ async build(repoRoot, options = {}) {
335
1167
  const nextRoutes = await resolveNextRoutes(repoRoot);
336
1168
  this.routes.push(...nextRoutes);
337
-
338
- // Resolve Fastify routes
339
- const { routes: fastifyRoutes, gaps } = await resolveFastifyRoutes(repoRoot);
1169
+
1170
+ const { routes: fastifyRoutes, gaps } = await resolveFastifyRoutes(repoRoot, options.fastifyEntry || null);
340
1171
  this.routes.push(...fastifyRoutes);
341
- this.gaps.push(...gaps);
342
-
343
- // Build indexes
344
- for (const route of this.routes) {
345
- const methodKey = route.method;
346
- if (!this.byMethod.has(methodKey)) this.byMethod.set(methodKey, []);
347
- this.byMethod.get(methodKey).push(route);
348
-
349
- const pathKey = route.path;
350
- if (!this.byPath.has(pathKey)) this.byPath.set(pathKey, []);
351
- this.byPath.get(pathKey).push(route);
352
-
353
- if (isParameterizedPath(route.path)) {
354
- this.parameterized.push(route);
355
- }
1172
+ this.gaps.push(...(gaps || []));
1173
+
1174
+ for (const r of this.routes) {
1175
+ const m = canonicalizeMethod(r.method);
1176
+ const p = canonicalizePath(r.path);
1177
+
1178
+ r.method = m;
1179
+ r.path = p;
1180
+
1181
+ if (!this.byMethod.has(m)) this.byMethod.set(m, []);
1182
+ this.byMethod.get(m).push(r);
1183
+
1184
+ if (!this.byPath.has(p)) this.byPath.set(p, []);
1185
+ this.byPath.get(p).push(r);
1186
+
1187
+ if (isParameterizedPath(p)) this.parameterized.push(r);
356
1188
  }
357
-
1189
+
358
1190
  return this;
359
1191
  }
360
-
361
- findRoutes(method, path) {
362
- const canonicalMethod = canonicalizeMethod(method);
363
- const canonicalPath = canonicalizePath(path);
1192
+
1193
+ findRoutes(method, p) {
1194
+ const m = canonicalizeMethod(method);
1195
+ const cp = canonicalizePath(p);
1196
+
364
1197
  const matches = [];
365
-
1198
+
366
1199
  // Exact path match
367
- const pathMatches = this.byPath.get(canonicalPath) || [];
368
- for (const route of pathMatches) {
369
- if (matchMethod(route.method, canonicalMethod)) {
370
- matches.push(route);
371
- }
1200
+ for (const r of this.byPath.get(cp) || []) {
1201
+ if (matchMethod(r.method, m)) matches.push(r);
372
1202
  }
373
-
374
- // Wildcard method match
375
- const wildcardMethods = this.byMethod.get('*') || [];
376
- for (const route of wildcardMethods) {
377
- if (route.path === canonicalPath && !matches.includes(route)) {
378
- matches.push(route);
379
- }
1203
+
1204
+ // Wildcard method match on exact path
1205
+ for (const r of this.byMethod.get("*") || []) {
1206
+ if (r.path === cp && !matches.includes(r)) matches.push(r);
380
1207
  }
381
-
1208
+
382
1209
  // Parameterized route match
383
- for (const route of this.parameterized) {
384
- if (matchPath(route.path, canonicalPath) && matchMethod(route.method, canonicalMethod)) {
385
- if (!matches.includes(route)) matches.push(route);
1210
+ for (const r of this.parameterized) {
1211
+ if (r.path === cp) continue;
1212
+ if (matchPath(r.path, cp) && matchMethod(r.method, m)) {
1213
+ if (!matches.includes(r)) matches.push(r);
386
1214
  }
387
1215
  }
388
-
1216
+
389
1217
  return matches;
390
1218
  }
391
-
392
- findClosestRoutes(path, limit = 3) {
393
- const canonicalPath = canonicalizePath(path);
394
- const pathParts = canonicalPath.split('/').filter(Boolean);
395
-
396
- const scored = this.routes.map(route => {
397
- const routeParts = route.path.split('/').filter(Boolean);
1219
+
1220
+ findClosestRoutes(p, limit = 3) {
1221
+ const cp = canonicalizePath(p);
1222
+ const pathParts = cp.split("/").filter(Boolean);
1223
+
1224
+ const scored = this.routes.map((r) => {
1225
+ const routeParts = r.path.split("/").filter(Boolean);
398
1226
  let score = 0;
399
-
1227
+
400
1228
  for (let i = 0; i < Math.min(pathParts.length, routeParts.length); i++) {
401
- if (pathParts[i] === routeParts[i] || routeParts[i].startsWith(':')) {
402
- score++;
403
- } else break;
1229
+ if (pathParts[i] === routeParts[i]) score += 1;
1230
+ else if (routeParts[i].startsWith(":")) score += 0.8;
1231
+ else if (routeParts[i].startsWith("*")) { score += 0.6; break; }
1232
+ else break;
404
1233
  }
405
-
406
- if (pathParts.length === routeParts.length) score += 0.5;
407
- return { route, score };
1234
+
1235
+ if (pathParts.length === routeParts.length) score += 0.25;
1236
+ if (isParameterizedPath(r.path)) score += 0.05;
1237
+
1238
+ return { route: r, score };
408
1239
  });
409
-
410
- return scored.sort((a, b) => b.score - a.score).slice(0, limit).map(s => s.route);
1240
+
1241
+ return scored
1242
+ .sort((a, b) => b.score - a.score)
1243
+ .slice(0, Math.max(0, limit))
1244
+ .map((s) => s.route);
411
1245
  }
412
-
1246
+
413
1247
  getRouteMap() {
414
1248
  return {
415
1249
  server: this.routes,
@@ -427,43 +1261,53 @@ class RouteIndex {
427
1261
  async function validateRouteExists(claim, repoRoot, routeIndex) {
428
1262
  const index = routeIndex || new RouteIndex();
429
1263
  if (!routeIndex) await index.build(repoRoot);
430
-
431
- const method = claim.method || '*';
432
- const routePath = claim.path;
433
-
1264
+
1265
+ const method = claim?.method || "*";
1266
+ const routePath = claim?.path;
1267
+
1268
+ if (!routePath) {
1269
+ return {
1270
+ result: "unknown",
1271
+ confidence: "low",
1272
+ evidence: [],
1273
+ nextSteps: ["Claim missing path"],
1274
+ };
1275
+ }
1276
+
434
1277
  const matches = index.findRoutes(method, routePath);
435
-
1278
+
436
1279
  if (matches.length > 0) {
1280
+ const best = matches[0];
437
1281
  return {
438
- result: 'true',
439
- confidence: matches[0].confidence,
440
- evidence: matches[0].evidence,
441
- matchedRoute: matches[0],
1282
+ result: "true",
1283
+ confidence: best.confidence || "med",
1284
+ evidence: best.evidence || [],
1285
+ matchedRoute: best,
442
1286
  };
443
1287
  }
444
-
445
- const closest = index.findClosestRoutes(routePath);
446
- const hasGaps = index.gaps.length > 0;
447
-
1288
+
1289
+ const closest = index.findClosestRoutes(routePath, 3);
1290
+ const hasGaps = (index.gaps || []).length > 0;
1291
+
448
1292
  if (hasGaps) {
449
1293
  return {
450
- result: 'unknown',
451
- confidence: 'low',
1294
+ result: "unknown",
1295
+ confidence: "low",
452
1296
  evidence: [],
453
1297
  closestRoutes: closest,
454
1298
  gaps: index.gaps,
455
- nextSteps: ['Some routes may not be detected due to unresolved plugins'],
1299
+ nextSteps: ["Some routes may not be detected due to unresolved plugins/imports."],
456
1300
  };
457
1301
  }
458
-
1302
+
459
1303
  return {
460
- result: 'false',
461
- confidence: 'high',
1304
+ result: "false",
1305
+ confidence: "high",
462
1306
  evidence: [],
463
1307
  closestRoutes: closest,
464
- nextSteps: closest.length > 0
465
- ? [`Did you mean: ${closest.map(r => `${r.method} ${r.path}`).join(', ')}?`]
466
- : ['No similar routes found'],
1308
+ nextSteps: closest.length
1309
+ ? [`Did you mean: ${closest.map(r => `${r.method} ${r.path}`).join(", ")}?`]
1310
+ : ["No similar routes found"],
467
1311
  };
468
1312
  }
469
1313
 
@@ -472,6 +1316,7 @@ module.exports = {
472
1316
  canonicalizeMethod,
473
1317
  resolveNextRoutes,
474
1318
  resolveFastifyRoutes,
1319
+ detectFastifyEntry,
475
1320
  RouteIndex,
476
1321
  validateRouteExists,
477
1322
  };