@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
@@ -18,6 +18,19 @@ const { buildEnforcementTruth } = require("./enforcement");
18
18
  // Multi-framework route detection v2
19
19
  const { resolveAllRoutes, detectFrameworks } = require("./route-detection");
20
20
 
21
+ // ---------- constants ----------
22
+ const IGNORE_GLOBS = [
23
+ "**/node_modules/**",
24
+ "**/.next/**",
25
+ "**/dist/**",
26
+ "**/build/**",
27
+ "**/.turbo/**",
28
+ "**/.git/**",
29
+ "**/.vibecheck/**",
30
+ ];
31
+
32
+ const CODE_FILE_GLOBS = ["**/*.{ts,tsx,js,jsx}"];
33
+
21
34
  // ---------- helpers ----------
22
35
  function sha256(text) {
23
36
  return "sha256:" + crypto.createHash("sha256").update(text).digest("hex");
@@ -29,15 +42,43 @@ function canonicalizeMethod(m) {
29
42
  return u;
30
43
  }
31
44
 
45
+ function stripQueryHash(s) {
46
+ const v = String(s || "");
47
+ const q = v.indexOf("?");
48
+ const h = v.indexOf("#");
49
+ const cut = (q === -1 ? h : (h === -1 ? q : Math.min(q, h)));
50
+ return cut === -1 ? v : v.slice(0, cut);
51
+ }
52
+
53
+ /**
54
+ * Canonical path rules:
55
+ * - ensure leading slash
56
+ * - collapse multiple slashes
57
+ * - strip query/hash
58
+ * - normalize Next dynamic segments:
59
+ * [[...slug]] -> *slug?
60
+ * [...slug] -> *slug
61
+ * [id] -> :id
62
+ */
32
63
  function canonicalizePath(p) {
33
- let s = String(p || "").trim();
64
+ let s = stripQueryHash(String(p || "").trim());
65
+
66
+ // If someone passed a full URL, only keep pathname-like portion if possible.
67
+ // (We still require local routes to start with "/" for client refs.)
68
+ const protoIdx = s.indexOf("://");
69
+ if (protoIdx !== -1) {
70
+ // attempt to strip scheme+host
71
+ const slashAfterHost = s.indexOf("/", protoIdx + 3);
72
+ s = slashAfterHost === -1 ? "/" : s.slice(slashAfterHost);
73
+ }
74
+
34
75
  if (!s.startsWith("/")) s = "/" + s;
35
76
  s = s.replace(/\/+/g, "/");
36
77
 
37
- // Next dynamic segments
78
+ // Next dynamic segments (filesystem style)
38
79
  s = s.replace(/\[\[\.{3}([^\]]+)\]\]/g, "*$1?"); // [[...slug]] -> *slug?
39
- s = s.replace(/\[\.{3}([^\]]+)\]/g, "*$1"); // [...slug] -> *slug
40
- s = s.replace(/\[([^\]]+)\]/g, ":$1"); // [id] -> :id
80
+ s = s.replace(/\[\.{3}([^\]]+)\]/g, "*$1"); // [...slug] -> *slug
81
+ s = s.replace(/\[([^\]]+)\]/g, ":$1"); // [id] -> :id
41
82
 
42
83
  if (s.length > 1) s = s.replace(/\/$/, "");
43
84
  return s;
@@ -51,12 +92,46 @@ function joinPaths(prefix, p) {
51
92
  return canonicalizePath(a + "/" + b);
52
93
  }
53
94
 
54
- function parseFile(code) {
55
- return parser.parse(code, { sourceType: "unambiguous", plugins: ["typescript", "jsx"] });
95
+ function parseFile(code, fileAbsForErrors) {
96
+ // Be permissive: production repos contain decorators, top-level await, etc.
97
+ return parser.parse(code, {
98
+ sourceType: "unambiguous",
99
+ errorRecovery: true,
100
+ allowImportExportEverywhere: true,
101
+ plugins: [
102
+ "typescript",
103
+ "jsx",
104
+ "dynamicImport",
105
+ "importMeta",
106
+ "topLevelAwait",
107
+ "classProperties",
108
+ "classPrivateProperties",
109
+ "classPrivateMethods",
110
+ "optionalChaining",
111
+ "nullishCoalescingOperator",
112
+ "decorators-legacy",
113
+ ],
114
+ sourceFilename: fileAbsForErrors || undefined,
115
+ });
56
116
  }
57
117
 
118
+ // File cache for performance (avoids reading the same file multiple times)
119
+ const _FILE_CACHE = new Map();
120
+
58
121
  function safeRead(fileAbs) {
59
- return fs.readFileSync(fileAbs, "utf8");
122
+ if (_FILE_CACHE.has(fileAbs)) return _FILE_CACHE.get(fileAbs);
123
+ try {
124
+ const content = fs.readFileSync(fileAbs, "utf8");
125
+ _FILE_CACHE.set(fileAbs, content);
126
+ return content;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // Clear cache to free memory after a scan (important for long-running processes)
133
+ function clearCache() {
134
+ _FILE_CACHE.clear();
60
135
  }
61
136
 
62
137
  function ensureDir(p) {
@@ -65,68 +140,154 @@ function ensureDir(p) {
65
140
 
66
141
  function evidenceFromLoc({ fileAbs, fileRel, loc, reason }) {
67
142
  if (!loc) return null;
68
- const lines = fs.readFileSync(fileAbs, "utf8").split(/\r?\n/);
143
+ const code = safeRead(fileAbs);
144
+ if (!code) return null;
145
+
146
+ const lines = code.split(/\r?\n/);
69
147
  const start = Math.max(1, loc.start?.line || 1);
70
148
  const end = Math.max(start, loc.end?.line || start);
71
149
  const snippet = lines.slice(start - 1, end).join("\n");
150
+
72
151
  return {
73
152
  id: `ev_${crypto.randomBytes(4).toString("hex")}`,
74
153
  file: fileRel,
75
154
  lines: `${start}-${end}`,
76
155
  snippetHash: sha256(snippet),
77
- reason
156
+ reason,
78
157
  };
79
158
  }
80
159
 
160
+ function normalizeRel(repoRoot, fileAbs) {
161
+ return path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
162
+ }
163
+
164
+ function scoreConfidence(c) {
165
+ if (c === "high") return 3;
166
+ if (c === "med") return 2;
167
+ if (c === "low") return 1;
168
+ return 0;
169
+ }
170
+
171
+ function isRouteGroupSegment(seg) {
172
+ // Next route group: (group)
173
+ return seg.startsWith("(") && seg.endsWith(")");
174
+ }
175
+
176
+ function isParallelSegment(seg) {
177
+ // Next parallel routes: @slot
178
+ return seg.startsWith("@");
179
+ }
180
+
81
181
  // ---------- Next: app router API ----------
82
- const HTTP_EXPORTS = new Set(["GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"]);
182
+ const HTTP_EXPORTS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
183
+
184
+ function nextAppApiPathFromRel(fileRel) {
185
+ const idx = fileRel.indexOf("app/api/");
186
+ if (idx === -1) return null;
187
+
188
+ let sub = fileRel.slice(idx + "app/api/".length);
189
+
190
+ // route.ts / route.js / route.tsx / route.jsx
191
+ sub = sub.replace(/\/route\.(ts|tsx|js|jsx)$/, "");
192
+
193
+ // remove route groups + parallel segments from the filesystem path
194
+ const parts = sub.split("/").filter(Boolean).filter((seg) => !isRouteGroupSegment(seg) && !isParallelSegment(seg));
195
+ sub = parts.join("/");
196
+
197
+ return canonicalizePath("/api/" + sub);
198
+ }
83
199
 
84
- async function resolveNextAppApiRoutes(repoRoot) {
85
- const files = await fg(["**/app/api/**/route.@(ts|js)"], {
200
+ async function resolveNextAppApiRoutes(repoRoot, stats) {
201
+ const files = await fg(["**/app/api/**/route.@(ts|tsx|js|jsx)"], {
86
202
  cwd: repoRoot,
87
203
  absolute: true,
88
- ignore: ["**/node_modules/**","**/.next/**","**/dist/**","**/build/**"]
204
+ ignore: IGNORE_GLOBS,
89
205
  });
90
206
 
91
207
  const out = [];
92
208
 
93
209
  for (const fileAbs of files) {
94
- const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
95
- const idx = fileRel.indexOf("app/api/");
96
- const sub = fileRel.slice(idx + "app/api/".length).replace(/\/route\.(ts|js)$/, "");
97
- const routePath = canonicalizePath("/api/" + sub);
210
+ const fileRel = normalizeRel(repoRoot, fileAbs);
211
+ const routePath = nextAppApiPathFromRel(fileRel);
212
+ if (!routePath) continue;
98
213
 
99
214
  const code = safeRead(fileAbs);
215
+ if (!code) continue;
216
+
100
217
  let ast;
101
- try { ast = parseFile(code); } catch { continue; }
218
+ try {
219
+ ast = parseFile(code, fileAbs);
220
+ } catch {
221
+ stats.parseErrors++;
222
+ continue;
223
+ }
102
224
 
103
225
  const methods = [];
104
- traverse(ast, {
105
- ExportNamedDeclaration(p) {
106
- const decl = p.node.declaration;
107
- if (t.isFunctionDeclaration(decl) && decl.id?.name) {
108
- const n = decl.id.name.toUpperCase();
109
- if (HTTP_EXPORTS.has(n)) methods.push({ method: n, loc: decl.loc });
110
- }
111
- }
112
- });
226
+
227
+ try {
228
+ traverse(ast, {
229
+ // export async function GET() {}
230
+ ExportNamedDeclaration(p) {
231
+ const decl = p.node.declaration;
232
+
233
+ if (t.isFunctionDeclaration(decl) && decl.id?.name) {
234
+ const n = decl.id.name.toUpperCase();
235
+ if (HTTP_EXPORTS.has(n)) methods.push({ method: n, loc: decl.loc, why: "export function" });
236
+ }
237
+
238
+ // export const GET = async () => {}
239
+ if (t.isVariableDeclaration(decl)) {
240
+ for (const d of decl.declarations) {
241
+ if (!t.isVariableDeclarator(d)) continue;
242
+ if (!t.isIdentifier(d.id)) continue;
243
+ const n = d.id.name.toUpperCase();
244
+ if (HTTP_EXPORTS.has(n)) methods.push({ method: n, loc: d.loc || decl.loc, why: "export const" });
245
+ }
246
+ }
247
+
248
+ // export { GET } from "./handler"
249
+ for (const s of p.node.specifiers || []) {
250
+ if (!t.isExportSpecifier(s)) continue;
251
+ if (!t.isIdentifier(s.exported)) continue;
252
+ const n = s.exported.name.toUpperCase();
253
+ if (HTTP_EXPORTS.has(n)) methods.push({ method: n, loc: s.loc || p.node.loc, why: "export re-export" });
254
+ }
255
+ },
256
+ });
257
+ } catch {
258
+ // Babel traverse can fail on some edge-case files; skip them
259
+ stats.parseErrors++;
260
+ continue;
261
+ }
113
262
 
114
263
  if (methods.length === 0) {
115
- out.push({ method: "*", path: routePath, handler: fileRel, confidence: "low", evidence: [] });
264
+ // Still include route.ts, but with "*" and low confidence to avoid missing-route spam.
265
+ out.push({
266
+ method: "*",
267
+ path: routePath,
268
+ handler: fileRel,
269
+ confidence: "low",
270
+ framework: "next",
271
+ evidence: [],
272
+ });
116
273
  continue;
117
274
  }
118
275
 
119
276
  for (const m of methods) {
120
277
  const ev = evidenceFromLoc({
121
- fileAbs, fileRel, loc: m.loc,
122
- reason: `Next app router export ${m.method}`
278
+ fileAbs,
279
+ fileRel,
280
+ loc: m.loc,
281
+ reason: `Next app router ${m.method} (${m.why})`,
123
282
  });
283
+
124
284
  out.push({
125
285
  method: m.method,
126
286
  path: routePath,
127
287
  handler: fileRel,
128
- confidence: "high",
129
- evidence: ev ? [ev] : []
288
+ confidence: m.why === "export re-export" ? "med" : "high",
289
+ framework: "next",
290
+ evidence: ev ? [ev] : [],
130
291
  });
131
292
  }
132
293
  }
@@ -135,51 +296,133 @@ async function resolveNextAppApiRoutes(repoRoot) {
135
296
  }
136
297
 
137
298
  // ---------- Next: pages router API ----------
138
- async function resolveNextPagesApiRoutes(repoRoot) {
139
- const files = await fg(["**/pages/api/**/*.@(ts|js)"], {
299
+ function nextPagesApiPathFromRel(fileRel) {
300
+ const idx = fileRel.indexOf("pages/api/");
301
+ if (idx === -1) return null;
302
+
303
+ let sub = fileRel.slice(idx + "pages/api/".length);
304
+ sub = sub.replace(/\.(ts|tsx|js|jsx)$/, "");
305
+
306
+ // pages/api/foo/index.ts -> /api/foo
307
+ if (sub === "index") sub = "";
308
+ sub = sub.replace(/\/index$/, "");
309
+
310
+ return canonicalizePath("/api/" + sub);
311
+ }
312
+
313
+ async function resolveNextPagesApiRoutes(repoRoot, stats) {
314
+ const files = await fg(["**/pages/api/**/*.@(ts|tsx|js|jsx)"], {
140
315
  cwd: repoRoot,
141
316
  absolute: true,
142
- ignore: ["**/node_modules/**","**/.next/**","**/dist/**","**/build/**"]
317
+ ignore: IGNORE_GLOBS,
143
318
  });
144
319
 
145
320
  const out = [];
321
+
146
322
  for (const fileAbs of files) {
147
- const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
148
- const idx = fileRel.indexOf("pages/api/");
149
- const sub = fileRel.slice(idx + "pages/api/".length).replace(/\.(ts|js)$/, "");
150
- const routePath = canonicalizePath("/api/" + sub);
323
+ const fileRel = normalizeRel(repoRoot, fileAbs);
324
+
325
+ // Skip Next.js special files that aren't API routes (_app, _document, _utils, etc.)
326
+ if (fileRel.includes("/_") && !fileRel.includes("/_next")) continue;
327
+
328
+ const routePath = nextPagesApiPathFromRel(fileRel);
329
+ if (!routePath) continue;
330
+
331
+ const code = safeRead(fileAbs);
332
+ if (!code) continue;
333
+
334
+ // Parse to verify it's actually a route (has export default)
335
+ let ast;
336
+ try {
337
+ ast = parseFile(code, fileAbs);
338
+ } catch {
339
+ stats.parseErrors++;
340
+ continue;
341
+ }
342
+
343
+ // Check for 'export default' (Required for Pages Router API routes)
344
+ // Files without default export are helper files (db.ts, types.ts, utils.ts)
345
+ let hasDefaultExport = false;
346
+ try {
347
+ traverse(ast, {
348
+ ExportDefaultDeclaration(p) {
349
+ hasDefaultExport = true;
350
+ p.stop(); // Found it, stop traversing
351
+ },
352
+ });
353
+ } catch {
354
+ // Traverse failed, skip this file
355
+ stats.parseErrors++;
356
+ continue;
357
+ }
358
+
359
+ if (!hasDefaultExport) continue; // It's a helper file, not an API route
151
360
 
152
361
  out.push({
153
- method: "*",
362
+ method: "*", // Pages router handles all methods in one function
154
363
  path: routePath,
155
364
  handler: fileRel,
156
365
  confidence: "med",
157
- evidence: []
366
+ framework: "next",
367
+ evidence: [],
158
368
  });
159
369
  }
370
+
160
371
  return out;
161
372
  }
162
373
 
163
374
  // ---------- minimal relative module resolver ----------
164
375
  function exists(p) {
165
- try { return fs.statSync(p).isFile(); } catch { return false; }
376
+ try {
377
+ return fs.statSync(p).isFile();
378
+ } catch {
379
+ return false;
380
+ }
166
381
  }
382
+
167
383
  function resolveRelativeModule(fromFileAbs, spec) {
168
384
  if (!spec || (!spec.startsWith("./") && !spec.startsWith("../"))) return null;
385
+
169
386
  const base = path.resolve(path.dirname(fromFileAbs), spec);
170
387
  const candidates = [
171
388
  base,
172
389
  base + ".ts",
390
+ base + ".tsx",
173
391
  base + ".js",
392
+ base + ".jsx",
174
393
  path.join(base, "index.ts"),
175
- path.join(base, "index.js")
394
+ path.join(base, "index.tsx"),
395
+ path.join(base, "index.js"),
396
+ path.join(base, "index.jsx"),
176
397
  ];
398
+
177
399
  for (const c of candidates) if (exists(c)) return c;
178
400
  return null;
179
401
  }
180
402
 
403
+ function extractRequireOrImportSpec(node) {
404
+ // require("./x")
405
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee, { name: "require" })) {
406
+ const a0 = node.arguments && node.arguments[0];
407
+ if (t.isStringLiteral(a0)) return a0.value;
408
+ }
409
+
410
+ // import("./x")
411
+ if (t.isCallExpression(node) && node.callee && node.callee.type === "Import") {
412
+ const a0 = node.arguments && node.arguments[0];
413
+ if (t.isStringLiteral(a0)) return a0.value;
414
+ }
415
+
416
+ // await import("./x")
417
+ if (t.isAwaitExpression(node)) {
418
+ return extractRequireOrImportSpec(node.argument);
419
+ }
420
+
421
+ return null;
422
+ }
423
+
181
424
  // ---------- Fastify route extraction ----------
182
- const FASTIFY_METHODS = new Set(["get","post","put","patch","delete","options","head","all"]);
425
+ const FASTIFY_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
183
426
 
184
427
  function isFastifyMethod(name) {
185
428
  return FASTIFY_METHODS.has(name);
@@ -222,18 +465,18 @@ function extractRouteObject(objExpr) {
222
465
  if (key === "method") {
223
466
  if (t.isStringLiteral(p.value)) methods = [p.value.value];
224
467
  if (t.isArrayExpression(p.value)) {
225
- methods = p.value.elements.filter(e => t.isStringLiteral(e)).map(e => e.value);
468
+ methods = p.value.elements.filter((e) => t.isStringLiteral(e)).map((e) => e.value);
226
469
  }
227
470
  }
228
471
 
229
472
  if (key === "handler") hasHandler = true;
230
- if (["preHandler","onRequest","preValidation","preSerialization"].includes(key)) hooks.push(key);
473
+ if (["preHandler", "onRequest", "preValidation", "preSerialization"].includes(key)) hooks.push(key);
231
474
  }
232
475
 
233
476
  return { url, methods, hasHandler, hooks };
234
477
  }
235
478
 
236
- function resolveFastifyRoutes(repoRoot, entryAbs) {
479
+ function resolveFastifyRoutes(repoRoot, entryAbs, stats) {
237
480
  const seen = new Set();
238
481
  const routes = [];
239
482
  const gaps = [];
@@ -242,217 +485,392 @@ function resolveFastifyRoutes(repoRoot, entryAbs) {
242
485
  if (!fileAbs || seen.has(fileAbs)) return;
243
486
  seen.add(fileAbs);
244
487
 
245
- const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
488
+ const fileRel = normalizeRel(repoRoot, fileAbs);
246
489
  const code = safeRead(fileAbs);
490
+ if (!code) return;
247
491
 
248
492
  let ast;
249
- try { ast = parseFile(code); } catch { return; }
493
+ try {
494
+ ast = parseFile(code, fileAbs);
495
+ } catch {
496
+ stats.parseErrors++;
497
+ return;
498
+ }
250
499
 
251
500
  // best-effort: fastify instance identifiers
252
501
  const fastifyNames = new Set(["fastify"]);
253
502
 
254
- traverse(ast, {
255
- VariableDeclarator(p) {
256
- if (!t.isIdentifier(p.node.id)) return;
257
- const id = p.node.id.name;
258
- const init = p.node.init;
259
- if (!init) return;
260
- if (t.isCallExpression(init) && t.isIdentifier(init.callee)) {
261
- const cal = init.callee.name;
262
- if (cal === "Fastify" || cal === "fastify") fastifyNames.add(id);
263
- }
264
- }
265
- });
503
+ try {
504
+ traverse(ast, {
505
+ VariableDeclarator(p) {
506
+ if (!t.isIdentifier(p.node.id)) return;
507
+ const id = p.node.id.name;
508
+ const init = p.node.init;
509
+ if (!init) return;
510
+
511
+ // const app = Fastify()
512
+ if (t.isCallExpression(init) && t.isIdentifier(init.callee)) {
513
+ const cal = init.callee.name;
514
+ if (cal === "Fastify" || cal === "fastify") fastifyNames.add(id);
515
+ }
516
+
517
+ // const app = require("fastify")()
518
+ if (t.isCallExpression(init) && t.isCallExpression(init.callee)) {
519
+ const inner = init.callee;
520
+ if (t.isIdentifier(inner.callee, { name: "require" }) && t.isStringLiteral(inner.arguments?.[0], { value: "fastify" })) {
521
+ fastifyNames.add(id);
522
+ }
523
+ }
524
+ },
525
+ });
526
+ } catch {
527
+ // Babel traverse can fail on some edge-case files; skip this step
528
+ }
266
529
 
267
530
  // helper: resolve imports for register(pluginIdent,...)
268
531
  function resolveImportSpecForLocal(localName) {
269
532
  let spec = null;
270
533
 
271
- traverse(ast, {
272
- ImportDeclaration(ip) {
273
- for (const s of ip.node.specifiers) {
274
- if ((t.isImportDefaultSpecifier(s) || t.isImportSpecifier(s)) && s.local.name === localName) {
275
- spec = ip.node.source.value;
534
+ try {
535
+ traverse(ast, {
536
+ ImportDeclaration(ip) {
537
+ for (const s of ip.node.specifiers) {
538
+ if ((t.isImportDefaultSpecifier(s) || t.isImportSpecifier(s)) && s.local.name === localName) {
539
+ spec = ip.node.source.value;
540
+ }
276
541
  }
277
- }
278
- },
279
- VariableDeclarator(vp) {
280
- if (!t.isIdentifier(vp.node.id) || vp.node.id.name !== localName) return;
281
- const init = vp.node.init;
282
- if (!t.isCallExpression(init)) return;
283
- if (!t.isIdentifier(init.callee) || init.callee.name !== "require") return;
284
- const a0 = init.arguments[0];
285
- if (t.isStringLiteral(a0)) spec = a0.value;
286
- }
287
- });
542
+ },
543
+ VariableDeclarator(vp) {
544
+ if (!t.isIdentifier(vp.node.id) || vp.node.id.name !== localName) return;
545
+ const init = vp.node.init;
546
+ if (!t.isCallExpression(init)) return;
547
+ if (!t.isIdentifier(init.callee) || init.callee.name !== "require") return;
548
+ const a0 = init.arguments[0];
549
+ if (t.isStringLiteral(a0)) spec = a0.value;
550
+ },
551
+ });
552
+ } catch {
553
+ // Babel traverse can fail; ignore
554
+ }
288
555
 
289
556
  return spec;
290
557
  }
291
558
 
292
- traverse(ast, {
293
- CallExpression(p) {
294
- const callee = p.node.callee;
295
- if (!t.isMemberExpression(callee)) return;
296
- if (!t.isIdentifier(callee.object) || !t.isIdentifier(callee.property)) return;
297
-
298
- const obj = callee.object.name;
299
- const prop = callee.property.name;
300
-
301
- if (!fastifyNames.has(obj)) return;
302
-
303
- // fastify.get('/x', ...)
304
- if (isFastifyMethod(prop)) {
305
- const routeStr = extractStringLiteral(p.node.arguments[0]);
306
- if (!routeStr) return;
307
-
308
- const fullPath = joinPaths(prefix, routeStr);
309
- const method = canonicalizeMethod(prop);
310
-
311
- const ev = evidenceFromLoc({
312
- fileAbs, fileRel, loc: p.node.loc,
313
- reason: `Fastify ${prop.toUpperCase()}("${routeStr}")`
314
- });
315
-
316
- routes.push({
317
- method,
318
- path: fullPath,
319
- handler: fileRel,
320
- confidence: "med",
321
- evidence: ev ? [ev] : []
322
- });
323
- return;
324
- }
325
-
326
- // fastify.route({ method, url, handler })
327
- if (prop === "route") {
328
- const arg0 = p.node.arguments[0];
329
- if (!t.isObjectExpression(arg0)) return;
330
-
331
- const r = extractRouteObject(arg0);
332
- if (!r.url) return;
333
-
334
- const fullPath = joinPaths(prefix, r.url);
335
- const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
336
-
337
- const ev = evidenceFromLoc({
338
- fileAbs, fileRel, loc: p.node.loc,
339
- reason: `Fastify.route({ url: "${r.url}" })`
340
- });
341
-
342
- for (const m of ms) {
559
+ try {
560
+ traverse(ast, {
561
+ CallExpression(p) {
562
+ const callee = p.node.callee;
563
+ if (!t.isMemberExpression(callee)) return;
564
+ if (!t.isIdentifier(callee.object) || !t.isIdentifier(callee.property)) return;
565
+
566
+ const obj = callee.object.name;
567
+ const prop = callee.property.name;
568
+
569
+ if (!fastifyNames.has(obj)) return;
570
+
571
+ // fastify.get('/x', ...)
572
+ if (isFastifyMethod(prop)) {
573
+ const routeStr = extractStringLiteral(p.node.arguments[0]);
574
+ if (!routeStr) return;
575
+
576
+ const fullPath = joinPaths(prefix, routeStr);
577
+ const method = canonicalizeMethod(prop);
578
+
579
+ const ev = evidenceFromLoc({
580
+ fileAbs,
581
+ fileRel,
582
+ loc: p.node.loc,
583
+ reason: `Fastify ${prop.toUpperCase()}("${routeStr}")`,
584
+ });
585
+
343
586
  routes.push({
344
- method: m,
587
+ method,
345
588
  path: fullPath,
346
589
  handler: fileRel,
347
- hooks: r.hooks,
348
- confidence: r.hasHandler ? "med" : "low",
349
- evidence: ev ? [ev] : []
590
+ confidence: "med",
591
+ framework: "fastify",
592
+ evidence: ev ? [ev] : [],
350
593
  });
594
+ return;
351
595
  }
352
- return;
353
- }
354
-
355
- // fastify.register(plugin, { prefix })
356
- if (prop === "register") {
357
- const pluginArg = p.node.arguments[0];
358
- const optsArg = p.node.arguments[1];
359
- const childPrefixRaw = extractPrefixFromOpts(optsArg);
360
- const childPrefix = childPrefixRaw ? joinPaths(prefix, childPrefixRaw) : prefix;
361
-
362
- // inline plugin
363
- if (t.isFunctionExpression(pluginArg) || t.isArrowFunctionExpression(pluginArg)) {
364
- const param0 = pluginArg.params[0];
365
- const innerName = t.isIdentifier(param0) ? param0.name : "fastify";
366
-
367
- // traverse just the plugin body (best effort)
368
- traverse(pluginArg.body, {
369
- CallExpression(pp) {
370
- const c = pp.node.callee;
371
- if (!t.isMemberExpression(c)) return;
372
- if (!t.isIdentifier(c.object) || !t.isIdentifier(c.property)) return;
373
- if (c.object.name !== innerName) return;
374
-
375
- const pr = c.property.name;
376
-
377
- if (isFastifyMethod(pr)) {
378
- const rs = extractStringLiteral(pp.node.arguments[0]);
379
- if (!rs) return;
380
- const fullPath = joinPaths(childPrefix, rs);
381
- const method = canonicalizeMethod(pr);
382
-
383
- const ev = evidenceFromLoc({
384
- fileAbs, fileRel, loc: pp.node.loc,
385
- reason: `Fastify plugin ${pr.toUpperCase()}("${rs}") prefix="${childPrefixRaw || ""}"`
386
- });
387
-
388
- routes.push({ method, path: fullPath, handler: fileRel, confidence: "med", evidence: ev ? [ev] : [] });
389
- }
390
596
 
391
- if (pr === "route") {
392
- const a0 = pp.node.arguments[0];
393
- if (!t.isObjectExpression(a0)) return;
394
- const r = extractRouteObject(a0);
395
- if (!r.url) return;
396
- const fullPath = joinPaths(childPrefix, r.url);
397
- const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
597
+ // fastify.route({ method, url, handler })
598
+ if (prop === "route") {
599
+ const arg0 = p.node.arguments[0];
600
+ if (!t.isObjectExpression(arg0)) return;
398
601
 
399
- const ev = evidenceFromLoc({
400
- fileAbs, fileRel, loc: pp.node.loc,
401
- reason: `Fastify plugin route("${r.url}") prefix="${childPrefixRaw || ""}"`
402
- });
602
+ const r = extractRouteObject(arg0);
603
+ if (!r.url) return;
403
604
 
404
- for (const m of ms) routes.push({ method: m, path: fullPath, handler: fileRel, confidence: "med", evidence: ev ? [ev] : [] });
405
- }
406
- }
407
- }, p.scope, p);
605
+ const fullPath = joinPaths(prefix, r.url);
606
+ const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
607
+
608
+ const ev = evidenceFromLoc({
609
+ fileAbs,
610
+ fileRel,
611
+ loc: p.node.loc,
612
+ reason: `Fastify.route({ url: "${r.url}" })`,
613
+ });
408
614
 
615
+ for (const m of ms) {
616
+ routes.push({
617
+ method: m,
618
+ path: fullPath,
619
+ handler: fileRel,
620
+ hooks: r.hooks,
621
+ confidence: r.hasHandler ? "med" : "low",
622
+ framework: "fastify",
623
+ evidence: ev ? [ev] : [],
624
+ });
625
+ }
409
626
  return;
410
627
  }
411
628
 
412
- // imported plugin identifier
413
- if (t.isIdentifier(pluginArg)) {
414
- const localName = pluginArg.name;
415
- const spec = resolveImportSpecForLocal(localName);
629
+ // fastify.register(plugin, { prefix })
630
+ if (prop === "register") {
631
+ const pluginArgRaw = p.node.arguments[0];
632
+ const optsArg = p.node.arguments[1];
633
+
634
+ const childPrefixRaw = extractPrefixFromOpts(optsArg);
635
+ const childPrefix = childPrefixRaw ? joinPaths(prefix, childPrefixRaw) : prefix;
636
+
637
+ // inline plugin
638
+ if (t.isFunctionExpression(pluginArgRaw) || t.isArrowFunctionExpression(pluginArgRaw)) {
639
+ const param0 = pluginArgRaw.params[0];
640
+ const innerName = t.isIdentifier(param0) ? param0.name : "fastify";
641
+
642
+ // traverse just the plugin body (best effort)
643
+ try {
644
+ traverse(
645
+ pluginArgRaw.body,
646
+ {
647
+ CallExpression(pp) {
648
+ const c = pp.node.callee;
649
+ if (!t.isMemberExpression(c)) return;
650
+ if (!t.isIdentifier(c.object) || !t.isIdentifier(c.property)) return;
651
+ if (c.object.name !== innerName) return;
652
+
653
+ const pr = c.property.name;
654
+
655
+ if (isFastifyMethod(pr)) {
656
+ const rs = extractStringLiteral(pp.node.arguments[0]);
657
+ if (!rs) return;
658
+
659
+ const fullPath = joinPaths(childPrefix, rs);
660
+ const method = canonicalizeMethod(pr);
661
+
662
+ const ev = evidenceFromLoc({
663
+ fileAbs,
664
+ fileRel,
665
+ loc: pp.node.loc,
666
+ reason: `Fastify plugin ${pr.toUpperCase()}("${rs}") prefix="${childPrefixRaw || ""}"`,
667
+ });
668
+
669
+ routes.push({
670
+ method,
671
+ path: fullPath,
672
+ handler: fileRel,
673
+ confidence: "med",
674
+ framework: "fastify",
675
+ evidence: ev ? [ev] : [],
676
+ });
677
+ }
678
+
679
+ if (pr === "route") {
680
+ const a0 = pp.node.arguments[0];
681
+ if (!t.isObjectExpression(a0)) return;
682
+
683
+ const r = extractRouteObject(a0);
684
+ if (!r.url) return;
685
+
686
+ const fullPath = joinPaths(childPrefix, r.url);
687
+ const ms = (r.methods.length ? r.methods : ["*"]).map(canonicalizeMethod);
688
+
689
+ const ev = evidenceFromLoc({
690
+ fileAbs,
691
+ fileRel,
692
+ loc: pp.node.loc,
693
+ reason: `Fastify plugin route("${r.url}") prefix="${childPrefixRaw || ""}"`,
694
+ });
695
+
696
+ for (const m of ms) {
697
+ routes.push({
698
+ method: m,
699
+ path: fullPath,
700
+ handler: fileRel,
701
+ confidence: "med",
702
+ framework: "fastify",
703
+ evidence: ev ? [ev] : [],
704
+ });
705
+ }
706
+ }
707
+ },
708
+ },
709
+ p.scope,
710
+ p
711
+ );
712
+ } catch {
713
+ // Inner traverse can fail; skip this plugin body
714
+ }
715
+
716
+ return;
717
+ }
416
718
 
417
- if (!spec) {
418
- gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, name: localName });
719
+ // Resolve dynamic require/import spec directly (fastify.register(require("./x")) / import("./x"))
720
+ const dynSpec = extractRequireOrImportSpec(pluginArgRaw);
721
+ if (dynSpec) {
722
+ const resolved = resolveRelativeModule(fileAbs, dynSpec);
723
+ if (!resolved) {
724
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, spec: dynSpec });
725
+ return;
726
+ }
727
+ scanFile(resolved, childPrefix);
419
728
  return;
420
729
  }
421
730
 
422
- const resolved = resolveRelativeModule(fileAbs, spec);
423
- if (!resolved) {
424
- gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, spec });
731
+ // imported plugin identifier
732
+ if (t.isIdentifier(pluginArgRaw)) {
733
+ const localName = pluginArgRaw.name;
734
+ const spec = resolveImportSpecForLocal(localName);
735
+
736
+ if (!spec) {
737
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, name: localName });
738
+ return;
739
+ }
740
+
741
+ const resolved = resolveRelativeModule(fileAbs, spec);
742
+ if (!resolved) {
743
+ gaps.push({ kind: "fastify_plugin_unresolved", file: fileRel, spec });
744
+ return;
745
+ }
746
+
747
+ scanFile(resolved, childPrefix);
425
748
  return;
426
749
  }
427
750
 
428
- scanFile(resolved, childPrefix);
751
+ // Anything else: unknown plugin shape. Mark a gap so analyzers can be lenient.
752
+ gaps.push({
753
+ kind: "fastify_plugin_unresolved",
754
+ file: fileRel,
755
+ note: "register() plugin not statically resolvable",
756
+ });
429
757
  }
430
- }
431
- }
432
- });
758
+ },
759
+ });
760
+ } catch {
761
+ // Babel traverse can fail on some edge-case files; skip
762
+ stats.parseErrors++;
763
+ }
433
764
  }
434
765
 
435
766
  scanFile(entryAbs, "/");
436
767
  return { routes, gaps };
437
768
  }
438
769
 
439
- // ---------- client refs (fetch + axios string literal only) ----------
770
+ // ---------- client refs (fetch + axios + template literals best-effort) ----------
440
771
  function isAxiosMember(node) {
441
- return t.isMemberExpression(node) &&
772
+ return (
773
+ t.isMemberExpression(node) &&
442
774
  t.isIdentifier(node.object) &&
443
775
  t.isIdentifier(node.property) &&
444
- ["get","post","put","patch","delete"].includes(node.property.name);
776
+ ["get", "post", "put", "patch", "delete"].includes(node.property.name)
777
+ );
778
+ }
779
+
780
+ function isAxiosCallee(node) {
781
+ return t.isIdentifier(node, { name: "axios" }) || isAxiosMember(node);
782
+ }
783
+
784
+ function extractUrlLike(node) {
785
+ // "literal"
786
+ if (t.isStringLiteral(node)) return { url: node.value, confidence: "high", note: "string" };
787
+
788
+ // `literal`
789
+ if (t.isTemplateLiteral(node) && node.expressions.length === 0) {
790
+ return { url: node.quasis.map((q) => q.value.cooked || "").join(""), confidence: "high", note: "template_static" };
791
+ }
792
+
793
+ // `/api/x/${id}` -> "/api/x/:id" (med confidence)
794
+ if (t.isTemplateLiteral(node) && node.quasis.length >= 1) {
795
+ const start = node.quasis[0]?.value?.cooked || "";
796
+ if (!start.startsWith("/")) return null;
797
+
798
+ let built = "";
799
+ for (let i = 0; i < node.quasis.length; i++) {
800
+ built += node.quasis[i].value.cooked || "";
801
+ if (i < node.expressions.length) {
802
+ const expr = node.expressions[i];
803
+ if (t.isIdentifier(expr)) built += `:${expr.name}`;
804
+ else built += "*";
805
+ }
806
+ }
807
+ return { url: built, confidence: "med", note: "template_dynamic" };
808
+ }
809
+
810
+ // "/api/x" + "/y" or "/api/x" + id (low confidence)
811
+ if (t.isBinaryExpression(node, { operator: "+" })) {
812
+ if (t.isStringLiteral(node.left) && node.left.value.startsWith("/")) {
813
+ const left = node.left.value;
814
+ let right = "";
815
+ if (t.isStringLiteral(node.right)) right = node.right.value;
816
+ else if (t.isIdentifier(node.right)) right = `:${node.right.name}`;
817
+ else right = "*";
818
+ return { url: left + right, confidence: "low", note: "concat" };
819
+ }
820
+ }
821
+
822
+ return null;
445
823
  }
446
824
 
447
- async function resolveClientRouteRefs(repoRoot) {
448
- const files = await fg(["**/*.{ts,tsx,js,jsx}"], {
825
+ function extractFetchMethodFromOptions(node) {
826
+ if (!t.isObjectExpression(node)) return "*";
827
+ for (const prop of node.properties) {
828
+ if (!t.isObjectProperty(prop)) continue;
829
+ const key =
830
+ t.isIdentifier(prop.key) ? prop.key.name :
831
+ t.isStringLiteral(prop.key) ? prop.key.value :
832
+ null;
833
+ if (key === "method" && t.isStringLiteral(prop.value)) return canonicalizeMethod(prop.value.value);
834
+ }
835
+ return "*";
836
+ }
837
+
838
+ function extractAxiosConfig(node) {
839
+ // axios({ url: "/api/x", method: "post" })
840
+ if (!t.isObjectExpression(node)) return null;
841
+
842
+ let urlNode = null;
843
+ let methodNode = null;
844
+
845
+ for (const prop of node.properties) {
846
+ if (!t.isObjectProperty(prop)) continue;
847
+ const key =
848
+ t.isIdentifier(prop.key) ? prop.key.name :
849
+ t.isStringLiteral(prop.key) ? prop.key.value :
850
+ null;
851
+ if (!key) continue;
852
+
853
+ if (key === "url") urlNode = prop.value;
854
+ if (key === "method") methodNode = prop.value;
855
+ }
856
+
857
+ const urlInfo = urlNode ? extractUrlLike(urlNode) : null;
858
+ if (!urlInfo) return null;
859
+
860
+ const method =
861
+ methodNode && t.isStringLiteral(methodNode)
862
+ ? canonicalizeMethod(methodNode.value)
863
+ : "*";
864
+
865
+ return { method, urlInfo };
866
+ }
867
+
868
+ async function resolveClientRouteRefs(repoRoot, stats) {
869
+ const files = await fg(CODE_FILE_GLOBS, {
449
870
  cwd: repoRoot,
450
871
  absolute: true,
451
872
  ignore: [
452
- "**/node_modules/**",
453
- "**/.next/**",
454
- "**/dist/**",
455
- "**/build/**",
873
+ ...IGNORE_GLOBS,
456
874
  "**/test/**",
457
875
  "**/tests/**",
458
876
  "**/__tests__/**",
@@ -462,141 +880,372 @@ async function resolveClientRouteRefs(repoRoot) {
462
880
  "**/jest.config.*",
463
881
  "**/*.mock.*",
464
882
  "**/mocks/**",
465
- "**/fixtures/**"
466
- ]
883
+ "**/fixtures/**",
884
+ ],
467
885
  });
468
886
 
469
887
  const out = [];
470
888
 
471
889
  for (const fileAbs of files) {
472
- const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
890
+ const fileRel = normalizeRel(repoRoot, fileAbs);
473
891
  const code = safeRead(fileAbs);
892
+ if (!code) continue;
474
893
 
475
894
  let ast;
476
- try { ast = parseFile(code); } catch { continue; }
477
-
478
- traverse(ast, {
479
- CallExpression(p) {
480
- const callee = p.node.callee;
481
-
482
- // fetch("/api/x", { method: "POST" })
483
- if (t.isIdentifier(callee) && callee.name === "fetch") {
484
- const a0 = p.node.arguments[0];
485
- if (!t.isStringLiteral(a0)) return;
486
-
487
- const url = a0.value;
488
- if (!url.startsWith("/")) return;
489
-
490
- let method = "*";
491
- const a1 = p.node.arguments[1];
492
- if (t.isObjectExpression(a1)) {
493
- for (const prop of a1.properties) {
494
- if (!t.isObjectProperty(prop)) continue;
495
- const key =
496
- t.isIdentifier(prop.key) ? prop.key.name :
497
- t.isStringLiteral(prop.key) ? prop.key.value :
498
- null;
499
- if (key === "method" && t.isStringLiteral(prop.value)) {
500
- method = canonicalizeMethod(prop.value.value);
895
+ try {
896
+ ast = parseFile(code, fileAbs);
897
+ } catch {
898
+ stats.parseErrors++;
899
+ continue;
900
+ }
901
+
902
+ try {
903
+ traverse(ast, {
904
+ CallExpression(p) {
905
+ const callee = p.node.callee;
906
+
907
+ // fetch(url, opts)
908
+ if (t.isIdentifier(callee, { name: "fetch" })) {
909
+ const a0 = p.node.arguments[0];
910
+ const a1 = p.node.arguments[1];
911
+
912
+ const urlInfo = extractUrlLike(a0);
913
+ if (!urlInfo) return;
914
+
915
+ const url = urlInfo.url;
916
+ if (!url.startsWith("/")) return;
917
+
918
+ const method = extractFetchMethodFromOptions(a1);
919
+
920
+ const ev = evidenceFromLoc({
921
+ fileAbs,
922
+ fileRel,
923
+ loc: p.node.loc,
924
+ reason: `Client fetch(${urlInfo.note}) "${stripQueryHash(url)}"`,
925
+ });
926
+
927
+ out.push({
928
+ method,
929
+ path: canonicalizePath(url),
930
+ source: fileRel,
931
+ confidence: urlInfo.confidence,
932
+ kind: "fetch",
933
+ evidence: ev ? [ev] : [],
934
+ });
935
+ return;
936
+ }
937
+
938
+ // axios.get("/api/x") etc
939
+ if (isAxiosMember(callee)) {
940
+ const verb = callee.property.name.toUpperCase();
941
+ const a0 = p.node.arguments[0];
942
+
943
+ const urlInfo = extractUrlLike(a0);
944
+ if (!urlInfo) return;
945
+
946
+ const url = urlInfo.url;
947
+ if (!url.startsWith("/")) return;
948
+
949
+ const ev = evidenceFromLoc({
950
+ fileAbs,
951
+ fileRel,
952
+ loc: p.node.loc,
953
+ reason: `Client axios.${verb.toLowerCase()}(${urlInfo.note}) "${stripQueryHash(url)}"`,
954
+ });
955
+
956
+ out.push({
957
+ method: canonicalizeMethod(verb),
958
+ path: canonicalizePath(url),
959
+ source: fileRel,
960
+ confidence: urlInfo.confidence,
961
+ kind: "axios_member",
962
+ evidence: ev ? [ev] : [],
963
+ });
964
+ return;
965
+ }
966
+
967
+ // axios({ url, method })
968
+ if (t.isIdentifier(callee, { name: "axios" })) {
969
+ const a0 = p.node.arguments[0];
970
+ const cfg = extractAxiosConfig(a0);
971
+ if (!cfg) return;
972
+
973
+ const url = cfg.urlInfo.url;
974
+ if (!url.startsWith("/")) return;
975
+
976
+ const ev = evidenceFromLoc({
977
+ fileAbs,
978
+ fileRel,
979
+ loc: p.node.loc,
980
+ reason: `Client axios(config:${cfg.urlInfo.note}) "${stripQueryHash(url)}"`,
981
+ });
982
+
983
+ out.push({
984
+ method: cfg.method,
985
+ path: canonicalizePath(url),
986
+ source: fileRel,
987
+ confidence: cfg.urlInfo.confidence === "high" ? "high" : "med",
988
+ kind: "axios_config",
989
+ evidence: ev ? [ev] : [],
990
+ });
991
+ return;
992
+ }
993
+
994
+ // useSWR("/api/user", fetcher) - Modern React data fetching
995
+ if (t.isIdentifier(callee, { name: "useSWR" })) {
996
+ const a0 = p.node.arguments[0];
997
+
998
+ const urlInfo = extractUrlLike(a0);
999
+ if (!urlInfo) return;
1000
+
1001
+ const url = urlInfo.url;
1002
+ if (!url.startsWith("/")) return;
1003
+
1004
+ const ev = evidenceFromLoc({
1005
+ fileAbs,
1006
+ fileRel,
1007
+ loc: p.node.loc,
1008
+ reason: `Client useSWR(${urlInfo.note}) "${stripQueryHash(url)}"`,
1009
+ });
1010
+
1011
+ out.push({
1012
+ method: "GET", // SWR is almost always GET
1013
+ path: canonicalizePath(url),
1014
+ source: fileRel,
1015
+ confidence: urlInfo.confidence,
1016
+ kind: "useSWR",
1017
+ evidence: ev ? [ev] : [],
1018
+ });
1019
+ return;
1020
+ }
1021
+
1022
+ // useQuery (React Query / TanStack Query) - Another popular data fetching library
1023
+ if (t.isIdentifier(callee, { name: "useQuery" })) {
1024
+ // useQuery({ queryKey: [...], queryFn: () => fetch("/api/x") })
1025
+ // or useQuery(["key"], () => fetch("/api/x"))
1026
+ const a0 = p.node.arguments[0];
1027
+
1028
+ // Try to extract URL from the arguments (often in queryFn)
1029
+ if (t.isObjectExpression(a0)) {
1030
+ for (const prop of a0.properties) {
1031
+ if (!t.isObjectProperty(prop)) continue;
1032
+ if (!t.isIdentifier(prop.key, { name: "queryFn" })) continue;
1033
+
1034
+ // queryFn is often an arrow function with fetch inside
1035
+ const fn = prop.value;
1036
+ if (t.isArrowFunctionExpression(fn) || t.isFunctionExpression(fn)) {
1037
+ // Best effort: look for string literals that look like API paths
1038
+ const fnCode = code.slice(fn.start, fn.end);
1039
+ const urlMatch = fnCode.match(/["'`](\/api\/[^"'`]+)["'`]/);
1040
+ if (urlMatch) {
1041
+ const url = urlMatch[1].split("?")[0].split("#")[0];
1042
+ const ev = evidenceFromLoc({
1043
+ fileAbs,
1044
+ fileRel,
1045
+ loc: p.node.loc,
1046
+ reason: `Client useQuery(queryFn) "${url}"`,
1047
+ });
1048
+
1049
+ out.push({
1050
+ method: "GET",
1051
+ path: canonicalizePath(url),
1052
+ source: fileRel,
1053
+ confidence: "low", // Less certain extraction
1054
+ kind: "useQuery",
1055
+ evidence: ev ? [ev] : [],
1056
+ });
1057
+ }
1058
+ }
501
1059
  }
502
1060
  }
503
1061
  }
1062
+ },
1063
+ });
1064
+ } catch {
1065
+ // Babel traverse can fail on some edge-case files; skip
1066
+ stats.parseErrors++;
1067
+ }
1068
+ }
504
1069
 
505
- const ev = evidenceFromLoc({
506
- fileAbs, fileRel, loc: p.node.loc,
507
- reason: `Client fetch("${url}")`
508
- });
509
-
510
- out.push({
511
- method,
512
- path: canonicalizePath(url),
513
- source: fileRel,
514
- confidence: "high",
515
- evidence: ev ? [ev] : []
516
- });
517
- return;
518
- }
519
-
520
- // axios.get("/api/x")
521
- if (isAxiosMember(callee)) {
522
- const verb = callee.property.name.toUpperCase();
523
- const a0 = p.node.arguments[0];
524
- if (!t.isStringLiteral(a0)) return;
525
-
526
- const url = a0.value;
527
- if (!url.startsWith("/")) return;
528
-
529
- const ev = evidenceFromLoc({
530
- fileAbs, fileRel, loc: p.node.loc,
531
- reason: `Client axios.${verb.toLowerCase()}("${url}")`
532
- });
533
-
534
- out.push({
535
- method: canonicalizeMethod(verb),
536
- path: canonicalizePath(url),
537
- source: fileRel,
538
- confidence: "high",
539
- evidence: ev ? [ev] : []
540
- });
541
- }
1070
+ return out;
1071
+ }
1072
+
1073
+ // ---------- workspace detection (best-effort, no new deps) ----------
1074
+ function readJsonIfExists(abs) {
1075
+ try {
1076
+ return JSON.parse(fs.readFileSync(abs, "utf8"));
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1081
+
1082
+ function detectWorkspaces(repoRoot) {
1083
+ const roots = [];
1084
+
1085
+ const pkg = readJsonIfExists(path.join(repoRoot, "package.json"));
1086
+ if (pkg && pkg.workspaces) {
1087
+ const ws = pkg.workspaces;
1088
+ const patterns = Array.isArray(ws) ? ws : Array.isArray(ws.packages) ? ws.packages : [];
1089
+ for (const pat of patterns) {
1090
+ if (typeof pat === "string") roots.push(pat);
1091
+ }
1092
+ }
1093
+
1094
+ // pnpm-workspace.yaml minimal parser (just handles `packages:` list)
1095
+ const pnpmWs = path.join(repoRoot, "pnpm-workspace.yaml");
1096
+ if (fs.existsSync(pnpmWs)) {
1097
+ const raw = safeRead(pnpmWs) || "";
1098
+ const lines = raw.split(/\r?\n/);
1099
+ let inPackages = false;
1100
+ for (const line of lines) {
1101
+ const l = line.trim();
1102
+ if (!l) continue;
1103
+ if (l.startsWith("packages:")) {
1104
+ inPackages = true;
1105
+ continue;
542
1106
  }
543
- });
1107
+ if (inPackages) {
1108
+ const m = l.match(/^-+\s*['"]?([^'"]+)['"]?\s*$/);
1109
+ if (m && m[1]) roots.push(m[1]);
1110
+ else if (!l.startsWith("-")) inPackages = false;
1111
+ }
1112
+ }
544
1113
  }
545
1114
 
546
- return out;
1115
+ // Expand to actual package.json roots
1116
+ const uniq = Array.from(new Set(roots)).filter(Boolean);
1117
+ const pkgJsonGlobs = uniq.map((p) => (p.endsWith("/") ? p : p + "/") + "package.json");
1118
+
1119
+ const found = pkgJsonGlobs.length
1120
+ ? fg.sync(pkgJsonGlobs, { cwd: repoRoot, absolute: true, ignore: IGNORE_GLOBS })
1121
+ : [];
1122
+
1123
+ const workspaces = found
1124
+ .map((abs) => path.dirname(abs))
1125
+ .map((abs) => normalizeRel(repoRoot, abs))
1126
+ .sort();
1127
+
1128
+ return workspaces;
547
1129
  }
548
1130
 
549
- // ---------- fastify entry detection ----------
550
- function detectFastifyEntry(repoRoot) {
551
- const candidates = [
552
- "src/server.ts","src/server.js",
553
- "server.ts","server.js",
554
- "src/index.ts","src/index.js",
555
- "index.ts","index.js"
556
- ];
557
- for (const rel of candidates) {
558
- const abs = path.join(repoRoot, rel);
559
- if (exists(abs)) return rel;
1131
+ // ---------- fastify entry detection (monorepo-friendly) ----------
1132
+ async function detectFastifyEntries(repoRoot) {
1133
+ // Keep it targeted (fast), but broad enough for monorepos.
1134
+ const candidates = await fg(
1135
+ [
1136
+ "**/{server,app,main,index}.{ts,tsx,js,jsx}",
1137
+ "**/src/{server,app,main,index}.{ts,tsx,js,jsx}",
1138
+ "**/*fastify*.{ts,tsx,js,jsx}",
1139
+ "**/*api*.{ts,tsx,js,jsx}",
1140
+ ],
1141
+ {
1142
+ cwd: repoRoot,
1143
+ absolute: true,
1144
+ ignore: IGNORE_GLOBS,
1145
+ }
1146
+ );
1147
+
1148
+ const entries = [];
1149
+ const fastifySignal = /\b(Fastify\s*\(|fastify\s*\(|require\(['"]fastify['"]\)|from\s+['"]fastify['"])\b/;
1150
+ const listenSignal = /\.\s*(listen|ready)\s*\(/;
1151
+
1152
+ for (const fileAbs of candidates) {
1153
+ const code = safeRead(fileAbs);
1154
+ if (!code) continue;
1155
+ // Must look like fastify + server start-ish signal (reduces noise)
1156
+ if (fastifySignal.test(code) && listenSignal.test(code)) {
1157
+ entries.push(fileAbs);
1158
+ }
560
1159
  }
561
- return null;
1160
+
1161
+ return Array.from(new Set(entries));
562
1162
  }
563
1163
 
564
1164
  // ---------- truthpack build/write ----------
565
1165
  async function buildTruthpack({ repoRoot, fastifyEntry }) {
1166
+ const stats = {
1167
+ parseErrors: 0,
1168
+ fastifyEntries: 0,
1169
+ fastifyRoutes: 0,
1170
+ nextAppRoutes: 0,
1171
+ nextPagesRoutes: 0,
1172
+ clientRefs: 0,
1173
+ serverRoutes: 0,
1174
+ gaps: 0,
1175
+ };
1176
+
1177
+ // Workspaces (for metadata + future use)
1178
+ const workspaces = detectWorkspaces(repoRoot);
1179
+
566
1180
  // Next.js routes (App Router + Pages Router)
567
- const nextApp = await resolveNextAppApiRoutes(repoRoot);
568
- const nextPages = await resolveNextPagesApiRoutes(repoRoot);
1181
+ const nextApp = await resolveNextAppApiRoutes(repoRoot, stats);
1182
+ const nextPages = await resolveNextPagesApiRoutes(repoRoot, stats);
1183
+
1184
+ stats.nextAppRoutes = nextApp.length;
1185
+ stats.nextPagesRoutes = nextPages.length;
569
1186
 
570
- // Fastify routes (legacy detection)
571
- const entryRel = fastifyEntry || detectFastifyEntry(repoRoot);
1187
+ // Fastify routes (monorepo-friendly)
572
1188
  let fastify = { routes: [], gaps: [] };
573
- if (entryRel) {
574
- const entryAbs = path.isAbsolute(entryRel) ? entryRel : path.join(repoRoot, entryRel);
575
- if (exists(entryAbs)) fastify = resolveFastifyRoutes(repoRoot, entryAbs);
1189
+
1190
+ if (fastifyEntry) {
1191
+ const entryAbs = path.isAbsolute(fastifyEntry) ? fastifyEntry : path.join(repoRoot, fastifyEntry);
1192
+ if (exists(entryAbs)) {
1193
+ const resolved = resolveFastifyRoutes(repoRoot, entryAbs, stats);
1194
+ fastify.routes.push(...resolved.routes);
1195
+ fastify.gaps.push(...resolved.gaps);
1196
+ stats.fastifyEntries = 1;
1197
+ }
1198
+ } else {
1199
+ const entries = await detectFastifyEntries(repoRoot);
1200
+ stats.fastifyEntries = entries.length;
1201
+
1202
+ for (const entryAbs of entries) {
1203
+ const resolved = resolveFastifyRoutes(repoRoot, entryAbs, stats);
1204
+ fastify.routes.push(...resolved.routes);
1205
+ fastify.gaps.push(...resolved.gaps);
1206
+ }
576
1207
  }
577
1208
 
1209
+ stats.fastifyRoutes = fastify.routes.length;
1210
+
578
1211
  // Multi-framework route detection v2 (Express, Flask, FastAPI, Django, Hono, Koa, etc.)
579
1212
  const multiFramework = await resolveAllRoutes(repoRoot);
580
1213
  const detectedFrameworks = await detectFrameworks(repoRoot);
581
1214
 
582
1215
  // Client refs (JS/TS fetch/axios + Python requests/httpx)
583
- const clientRefs = await resolveClientRouteRefs(repoRoot);
584
- const allClientRefs = [...clientRefs, ...multiFramework.clientRefs];
1216
+ const clientRefs = await resolveClientRouteRefs(repoRoot, stats);
1217
+ const allClientRefs = [...clientRefs, ...(multiFramework.clientRefs || [])];
1218
+
1219
+ stats.clientRefs = allClientRefs.length;
1220
+
1221
+ // Merge all server routes (dedupe with priority)
1222
+ const serverRoutesRaw = [...nextApp, ...nextPages, ...(fastify.routes || []), ...(multiFramework.routes || [])];
585
1223
 
586
- // Merge all server routes (dedupe by method+path)
587
- const serverRoutesRaw = [...nextApp, ...nextPages, ...fastify.routes, ...multiFramework.routes];
588
- const seenRoutes = new Set();
589
- const server = [];
1224
+ const bestByKey = new Map(); // key = method:path
590
1225
  for (const r of serverRoutesRaw) {
591
- const key = `${r.method}:${r.path}`;
592
- if (!seenRoutes.has(key)) {
593
- seenRoutes.add(key);
594
- server.push(r);
1226
+ const key = `${canonicalizeMethod(r.method)}:${canonicalizePath(r.path)}`;
1227
+
1228
+ const prev = bestByKey.get(key);
1229
+ if (!prev) {
1230
+ bestByKey.set(key, { ...r, method: canonicalizeMethod(r.method), path: canonicalizePath(r.path) });
1231
+ continue;
1232
+ }
1233
+
1234
+ // Prefer higher confidence, and prefer specific method over "*"
1235
+ const prevScore = scoreConfidence(prev.confidence) + (prev.method === "*" ? 0 : 1);
1236
+ const curScore = scoreConfidence(r.confidence) + (r.method === "*" ? 0 : 1);
1237
+
1238
+ if (curScore > prevScore) {
1239
+ bestByKey.set(key, { ...r, method: canonicalizeMethod(r.method), path: canonicalizePath(r.path) });
595
1240
  }
596
1241
  }
597
1242
 
1243
+ const server = Array.from(bestByKey.values());
1244
+ stats.serverRoutes = server.length;
1245
+
598
1246
  // Merge gaps
599
1247
  const allGaps = [...(fastify.gaps || []), ...(multiFramework.gaps || [])];
1248
+ stats.gaps = allGaps.length;
600
1249
 
601
1250
  // Env Truth v1
602
1251
  const env = await buildEnvTruth(repoRoot);
@@ -611,23 +1260,32 @@ async function buildTruthpack({ repoRoot, fastifyEntry }) {
611
1260
  const enforcement = buildEnforcementTruth(repoRoot, server);
612
1261
 
613
1262
  // Determine frameworks
614
- const frameworks = new Set(["next", "fastify"]);
615
- detectedFrameworks.forEach(f => frameworks.add(f));
616
- server.forEach(r => r.framework && frameworks.add(r.framework));
1263
+ const frameworks = new Set();
1264
+ detectedFrameworks.forEach((f) => frameworks.add(f));
1265
+ server.forEach((r) => r.framework && frameworks.add(r.framework));
1266
+ if (nextApp.length || nextPages.length) frameworks.add("next");
1267
+ if (fastify.routes.length) frameworks.add("fastify");
617
1268
 
618
1269
  const truthpack = {
619
1270
  meta: {
620
- version: "2.0.0",
1271
+ version: "2.1.0",
621
1272
  generatedAt: new Date().toISOString(),
622
1273
  repoRoot,
623
- commit: { sha: process.env.VIBECHECK_COMMIT_SHA || "unknown" }
1274
+ commit: { sha: process.env.VIBECHECK_COMMIT_SHA || "unknown" },
1275
+ stats,
1276
+ },
1277
+ project: {
1278
+ frameworks: Array.from(frameworks),
1279
+ workspaces,
1280
+ entrypoints: {
1281
+ fastify: fastifyEntry ? [fastifyEntry] : [], // entries auto-detected are not stored as rel here by default
1282
+ },
624
1283
  },
625
- project: { frameworks: Array.from(frameworks), workspaces: [], entrypoints: [] },
626
1284
  routes: { server, clientRefs: allClientRefs, gaps: allGaps },
627
1285
  env,
628
1286
  auth,
629
1287
  billing,
630
- enforcement
1288
+ enforcement,
631
1289
  };
632
1290
 
633
1291
  const hash = sha256(JSON.stringify(truthpack));
@@ -639,21 +1297,27 @@ async function buildTruthpack({ repoRoot, fastifyEntry }) {
639
1297
  function writeTruthpack(repoRoot, truthpack) {
640
1298
  const dir = path.join(repoRoot, ".vibecheck");
641
1299
  ensureDir(dir);
642
- // Spec: .vibecheck/truthpack.json (not .vibecheck/truth/truthpack.json)
643
- fs.writeFileSync(path.join(dir, "truthpack.json"), JSON.stringify(truthpack, null, 2));
1300
+
1301
+ const target = path.join(dir, "truthpack.json");
1302
+ const tmp = path.join(dir, `truthpack.${process.pid}.${Date.now()}.tmp.json`);
1303
+
1304
+ // atomic-ish write: write tmp then rename
1305
+ fs.writeFileSync(tmp, JSON.stringify(truthpack, null, 2));
1306
+ fs.renameSync(tmp, target);
644
1307
  }
645
1308
 
646
1309
  function loadTruthpack(repoRoot) {
647
- // Spec path: .vibecheck/truthpack.json
648
1310
  const specPath = path.join(repoRoot, ".vibecheck", "truthpack.json");
649
- // Legacy path: .vibecheck/truth/truthpack.json (backward compat)
650
1311
  const legacyPath = path.join(repoRoot, ".vibecheck", "truth", "truthpack.json");
651
-
652
- try {
653
- return JSON.parse(fs.readFileSync(specPath, "utf8"));
654
- } catch {
655
- // Try legacy path
656
- try { return JSON.parse(fs.readFileSync(legacyPath, "utf8")); } catch { return null; }
1312
+
1313
+ try {
1314
+ return JSON.parse(fs.readFileSync(specPath, "utf8"));
1315
+ } catch {
1316
+ try {
1317
+ return JSON.parse(fs.readFileSync(legacyPath, "utf8"));
1318
+ } catch {
1319
+ return null;
1320
+ }
657
1321
  }
658
1322
  }
659
1323
 
@@ -663,5 +1327,24 @@ module.exports = {
663
1327
  buildTruthpack,
664
1328
  writeTruthpack,
665
1329
  loadTruthpack,
666
- detectFastifyEntry
1330
+ clearCache, // Clear file cache to free memory (important for long-running processes)
1331
+ // kept for backward compatibility if other code imports it,
1332
+ // but fastifyEntry is now optional and auto-detected.
1333
+ detectFastifyEntry: function detectFastifyEntry(repoRoot) {
1334
+ const candidates = [
1335
+ "src/server.ts",
1336
+ "src/server.js",
1337
+ "server.ts",
1338
+ "server.js",
1339
+ "src/index.ts",
1340
+ "src/index.js",
1341
+ "index.ts",
1342
+ "index.js",
1343
+ ];
1344
+ for (const rel of candidates) {
1345
+ const abs = path.join(repoRoot, rel);
1346
+ if (exists(abs)) return rel;
1347
+ }
1348
+ return null;
1349
+ },
667
1350
  };