@vibecheck-ai/mcp 24.6.9 → 24.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/APITruthEngine-IZRR3NT5-LPFUOMLD.js +9 -0
- package/dist/CredentialsEngine-B66ANCBB-HY5ZQTSX.js +9 -0
- package/dist/EnvVarEngine-ZFNW2XKP-6HRTZULP.js +9 -0
- package/dist/ErrorHandlingEngine-FG65SFRB-4NEANCMF.js +11 -0
- package/dist/FrameworkPackEngine-RRBJW4MC-KH7WRXXS.js +12 -0
- package/dist/GhostRouteEngine-UMYBCOCL-MSZOPVZY.js +9 -0
- package/dist/LogicGapEngine-OK5UKZQ5-YGXZDERB.js +11 -0
- package/dist/PhantomDepEngine-5O7Z7MDE-4A37GGL4.js +10 -0
- package/dist/SecurityEngine-MVMRPKLH-BNP7IC46.js +9 -0
- package/dist/VersionHallucinationEngine-673DJ26J-BD4SK6JX.js +9 -0
- package/dist/chokidar-CI5VJY5M.js +2414 -0
- package/dist/chunk-43XAAYST.js +863 -0
- package/dist/chunk-5DADZJ3D.js +650 -0
- package/dist/chunk-DDTUTWRY.js +605 -0
- package/dist/chunk-DGNNNAVK.js +304 -0
- package/dist/chunk-F34MHA6A.js +772 -0
- package/dist/chunk-FGMVY5QW.js +42 -0
- package/dist/chunk-FMRX5OVJ.js +1968 -0
- package/dist/chunk-FRK2XZX5.js +213309 -0
- package/dist/chunk-J52EUKKW.js +196 -0
- package/dist/chunk-JZSHXEYP.js +915 -0
- package/dist/chunk-LQSBUKYZ.js +551 -0
- package/dist/chunk-MUP4JXOF.js +219 -0
- package/dist/chunk-NR36RTVO.js +152 -0
- package/dist/chunk-QGPX6H6L.js +3044 -0
- package/dist/chunk-QYXENOVK.js +499 -0
- package/dist/chunk-RR5ETBSV.js +66 -0
- package/dist/chunk-WUHPSW7M.js +11130 -0
- package/dist/chunk-YWUMPN4Z.js +53 -0
- package/dist/dist-HFMJ3GIR.js +1091 -0
- package/dist/dist-JUOVMQEA.js +9 -0
- package/dist/dist-NXITTS32-O3XLWR6T.js +386 -0
- package/dist/dist-Y2Z46SBD.js +22 -0
- package/dist/fingerprint-NOJ7TDB6-K6SB7LCZ.js +9 -0
- package/dist/index.js +5462 -4676
- package/dist/semantic-WW6XVII4.js +8544 -0
- package/dist/transformers.node-K4WKH4PR.js +45809 -0
- package/dist/tree-sitter-AGICL65I.js +1412 -0
- package/dist/tree-sitter-H5E7LKR4-MKO3NNLJ.js +9 -0
- package/package.json +7 -6
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as fsp from 'fs/promises';
|
|
7
|
+
|
|
8
|
+
createRequire(import.meta.url);
|
|
9
|
+
const __filename$1 = fileURLToPath(import.meta.url);
|
|
10
|
+
dirname(__filename$1);
|
|
11
|
+
var CALL_PATTERNS = [
|
|
12
|
+
// fetch('/api/...') or fetch("/api/...") or fetch(`/api/...`)
|
|
13
|
+
{
|
|
14
|
+
regex: /\bfetch\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
15
|
+
client: "fetch",
|
|
16
|
+
pathGroup: 1
|
|
17
|
+
},
|
|
18
|
+
// axios.get|post|put|delete|patch|head|options('/api/...')
|
|
19
|
+
{
|
|
20
|
+
regex: /\baxios\.(?:get|post|put|delete|patch|head|options|request)\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
21
|
+
client: "axios",
|
|
22
|
+
pathGroup: 1
|
|
23
|
+
},
|
|
24
|
+
// axios('/api/...') — shorthand
|
|
25
|
+
{
|
|
26
|
+
regex: /\baxios\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
27
|
+
client: "axios",
|
|
28
|
+
pathGroup: 1
|
|
29
|
+
},
|
|
30
|
+
// ky.get|post|put|delete|patch('/api/...')
|
|
31
|
+
{
|
|
32
|
+
regex: /\bky\s*\.\s*(?:get|post|put|delete|patch)\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
33
|
+
client: "ky",
|
|
34
|
+
pathGroup: 1
|
|
35
|
+
},
|
|
36
|
+
// got.get|post|put|delete|patch('/api/...')
|
|
37
|
+
{
|
|
38
|
+
regex: /\bgot\s*\.\s*(?:get|post|put|delete|patch)\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
39
|
+
client: "got",
|
|
40
|
+
pathGroup: 1
|
|
41
|
+
},
|
|
42
|
+
// $fetch('/api/...') — Nuxt/ofetch
|
|
43
|
+
{
|
|
44
|
+
regex: /\b\$?fetch\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
45
|
+
client: "$fetch",
|
|
46
|
+
pathGroup: 1
|
|
47
|
+
},
|
|
48
|
+
// ofetch('/api/...')
|
|
49
|
+
{
|
|
50
|
+
regex: /\bofetch\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
51
|
+
client: "ofetch",
|
|
52
|
+
pathGroup: 1
|
|
53
|
+
},
|
|
54
|
+
// superagent: request.get('/api/...')
|
|
55
|
+
{
|
|
56
|
+
regex: /\brequest\s*\.\s*(?:get|post|put|del|delete|patch)\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
57
|
+
client: "superagent",
|
|
58
|
+
pathGroup: 1
|
|
59
|
+
},
|
|
60
|
+
// new URL('/api/...', base)
|
|
61
|
+
{
|
|
62
|
+
regex: /new\s+URL\s*\(\s*[`'"](\/?api\/[^`'"?#\s]+)[`'"]/g,
|
|
63
|
+
client: "URL",
|
|
64
|
+
pathGroup: 1
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
var INTERPOLATION_RE = /\$\{[^}]+\}/;
|
|
68
|
+
var SAFE_PREFIXES = /* @__PURE__ */ new Set([
|
|
69
|
+
"/api/auth",
|
|
70
|
+
// NextAuth.js / Auth.js
|
|
71
|
+
"/api/_next",
|
|
72
|
+
// Next.js internals
|
|
73
|
+
"/api/trpc",
|
|
74
|
+
// tRPC router
|
|
75
|
+
"/api/clerk",
|
|
76
|
+
// Clerk auth
|
|
77
|
+
"/api/supabase",
|
|
78
|
+
// Supabase auth helpers
|
|
79
|
+
"/api/uploadthing",
|
|
80
|
+
// UploadThing
|
|
81
|
+
"/api/inngest",
|
|
82
|
+
// Inngest event handler
|
|
83
|
+
"/api/sentry",
|
|
84
|
+
// Sentry tunnel
|
|
85
|
+
"/api/stripe/webhook",
|
|
86
|
+
// Common Stripe webhook path
|
|
87
|
+
"/api/graphql",
|
|
88
|
+
// GraphQL endpoint (handled by schema, not file-per-route)
|
|
89
|
+
"/api/health",
|
|
90
|
+
// Health check endpoints (often middleware)
|
|
91
|
+
"/api/ping",
|
|
92
|
+
// Ping/health
|
|
93
|
+
"/api/webhooks",
|
|
94
|
+
// Generic webhook handlers
|
|
95
|
+
"/api/callback",
|
|
96
|
+
// OAuth/auth callback endpoints
|
|
97
|
+
"/api/oauth",
|
|
98
|
+
// OAuth flow endpoints
|
|
99
|
+
"/api/cron",
|
|
100
|
+
// Vercel/Next.js cron handlers
|
|
101
|
+
"/api/revalidate"
|
|
102
|
+
// Next.js ISR revalidation
|
|
103
|
+
]);
|
|
104
|
+
function isSafePath(apiPath) {
|
|
105
|
+
for (const prefix of SAFE_PREFIXES) {
|
|
106
|
+
if (apiPath === prefix || apiPath.startsWith(prefix + "/")) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
function buildLineRegions(lines) {
|
|
111
|
+
const regions = [];
|
|
112
|
+
let inBlock = false;
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (inBlock) {
|
|
115
|
+
const closeIdx = line.indexOf("*/");
|
|
116
|
+
if (closeIdx === -1) {
|
|
117
|
+
regions.push({ inComment: true, stringRanges: [] });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
inBlock = false;
|
|
121
|
+
regions.push({
|
|
122
|
+
inComment: false,
|
|
123
|
+
codeStart: closeIdx + 2,
|
|
124
|
+
stringRanges: extractStringRanges(line, closeIdx + 2)
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const trimmed = line.trimStart();
|
|
129
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#")) {
|
|
130
|
+
regions.push({ inComment: true, stringRanges: [] });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const blockStart = line.indexOf("/*");
|
|
134
|
+
if (blockStart !== -1) {
|
|
135
|
+
const blockEnd = line.indexOf("*/", blockStart + 2);
|
|
136
|
+
if (blockEnd === -1) {
|
|
137
|
+
inBlock = true;
|
|
138
|
+
const isFullComment = line.slice(0, blockStart).trim() === "";
|
|
139
|
+
regions.push({ inComment: isFullComment, stringRanges: [] });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
regions.push({
|
|
143
|
+
inComment: false,
|
|
144
|
+
codeStart: blockEnd + 2,
|
|
145
|
+
stringRanges: extractStringRanges(line, blockEnd + 2)
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
regions.push({ inComment: false, stringRanges: extractStringRanges(line) });
|
|
150
|
+
}
|
|
151
|
+
return regions;
|
|
152
|
+
}
|
|
153
|
+
function extractStringRanges(line, from = 0) {
|
|
154
|
+
const ranges = [];
|
|
155
|
+
let i = from;
|
|
156
|
+
while (i < line.length) {
|
|
157
|
+
line[i];
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
return ranges;
|
|
161
|
+
}
|
|
162
|
+
function deterministicId(uri, line, col, apiPath) {
|
|
163
|
+
const input = `${uri}::${line}::${col}::ghost::${apiPath}`;
|
|
164
|
+
let hash = 2166136261;
|
|
165
|
+
for (let i = 0; i < input.length; i++) {
|
|
166
|
+
hash ^= input.charCodeAt(i);
|
|
167
|
+
hash = hash * 16777619 >>> 0;
|
|
168
|
+
}
|
|
169
|
+
return `ghost-route-${hash.toString(16).padStart(8, "0")}`;
|
|
170
|
+
}
|
|
171
|
+
function levenshtein(a, b) {
|
|
172
|
+
if (a === b) return 0;
|
|
173
|
+
if (a.length === 0) return b.length;
|
|
174
|
+
if (b.length === 0) return a.length;
|
|
175
|
+
if (a.length > b.length) [a, b] = [b, a];
|
|
176
|
+
const m = a.length;
|
|
177
|
+
const n = b.length;
|
|
178
|
+
const row = new Uint16Array(m + 1);
|
|
179
|
+
for (let i = 0; i <= m; i++) row[i] = i;
|
|
180
|
+
for (let j = 1; j <= n; j++) {
|
|
181
|
+
let prev = row[0];
|
|
182
|
+
row[0] = j;
|
|
183
|
+
for (let i = 1; i <= m; i++) {
|
|
184
|
+
const temp = row[i];
|
|
185
|
+
row[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, row[i], row[i - 1]);
|
|
186
|
+
prev = temp;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return row[m];
|
|
190
|
+
}
|
|
191
|
+
function compileRoutePattern(routePath) {
|
|
192
|
+
let isDynamic = false;
|
|
193
|
+
let isCatchAll = false;
|
|
194
|
+
let pattern = routePath.replace(/\[\[\.\.\.(\w+)\]\]/g, () => {
|
|
195
|
+
isDynamic = true;
|
|
196
|
+
isCatchAll = true;
|
|
197
|
+
return "(?:\\/.*)?";
|
|
198
|
+
}).replace(/\[\.\.\.(\w+)\]/g, () => {
|
|
199
|
+
isDynamic = true;
|
|
200
|
+
isCatchAll = true;
|
|
201
|
+
return "\\/.*";
|
|
202
|
+
}).replace(/\[(\w+)\]/g, () => {
|
|
203
|
+
isDynamic = true;
|
|
204
|
+
return "\\/[^/]+";
|
|
205
|
+
});
|
|
206
|
+
pattern = pattern.replace(/\//g, "\\/");
|
|
207
|
+
pattern = pattern.replace(/\\\\\//g, "\\/");
|
|
208
|
+
return {
|
|
209
|
+
regex: new RegExp(`^${pattern}$`),
|
|
210
|
+
isDynamic,
|
|
211
|
+
isCatchAll
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
var ROUTE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
215
|
+
var NEXTJS_ROUTE_FILES = /* @__PURE__ */ new Set(["route", "index"]);
|
|
216
|
+
async function buildRouteIndex(workspaceRoot, framework) {
|
|
217
|
+
const routes = [];
|
|
218
|
+
const routeDirs = getRouteDirs(workspaceRoot, framework);
|
|
219
|
+
for (const { dir, style } of routeDirs) {
|
|
220
|
+
if (!fs.existsSync(dir)) continue;
|
|
221
|
+
await walkRouteDir(dir, dir, style, routes);
|
|
222
|
+
}
|
|
223
|
+
return { routes, framework, builtAt: Date.now() };
|
|
224
|
+
}
|
|
225
|
+
function getRouteDirs(root, framework) {
|
|
226
|
+
const dirs = [];
|
|
227
|
+
if (framework === "nextjs" || framework === "unknown") {
|
|
228
|
+
dirs.push({ dir: path.join(root, "pages", "api"), style: "pages" });
|
|
229
|
+
dirs.push({ dir: path.join(root, "src", "pages", "api"), style: "pages" });
|
|
230
|
+
dirs.push({ dir: path.join(root, "app", "api"), style: "app" });
|
|
231
|
+
dirs.push({ dir: path.join(root, "src", "app", "api"), style: "app" });
|
|
232
|
+
}
|
|
233
|
+
if (framework === "nuxt") {
|
|
234
|
+
dirs.push({ dir: path.join(root, "server", "api"), style: "nuxt-server" });
|
|
235
|
+
dirs.push({ dir: path.join(root, "server", "routes"), style: "nuxt-server" });
|
|
236
|
+
}
|
|
237
|
+
if (framework === "sveltekit") {
|
|
238
|
+
dirs.push({ dir: path.join(root, "src", "routes", "api"), style: "sveltekit" });
|
|
239
|
+
}
|
|
240
|
+
if (framework === "remix") {
|
|
241
|
+
dirs.push({ dir: path.join(root, "app", "routes"), style: "remix" });
|
|
242
|
+
}
|
|
243
|
+
return dirs;
|
|
244
|
+
}
|
|
245
|
+
async function walkRouteDir(baseDir, currentDir, style, routes) {
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = await fsp.readdir(currentDir, { withFileTypes: true });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const code = err.code;
|
|
251
|
+
if (code !== "ENOENT" && process.env.NODE_ENV !== "test") {
|
|
252
|
+
console.warn(
|
|
253
|
+
`[vibecheck:ghost-route] Cannot read route dir ${currentDir}: ${code ?? err.message}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const full = path.join(currentDir, entry.name);
|
|
260
|
+
if (entry.isDirectory()) {
|
|
261
|
+
if (entry.name.startsWith("(") && entry.name.endsWith(")")) {
|
|
262
|
+
await walkRouteDir(baseDir, full, style, routes);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (entry.name.startsWith("_")) continue;
|
|
266
|
+
await walkRouteDir(baseDir, full, style, routes);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!entry.isFile()) continue;
|
|
270
|
+
const ext = path.extname(entry.name);
|
|
271
|
+
if (!ROUTE_EXTS.has(ext)) continue;
|
|
272
|
+
const baseName = path.basename(entry.name, ext);
|
|
273
|
+
let routePath;
|
|
274
|
+
if (style === "app" || style === "sveltekit") {
|
|
275
|
+
if (!NEXTJS_ROUTE_FILES.has(baseName) && baseName !== "+server") continue;
|
|
276
|
+
const relDir = path.relative(baseDir, currentDir).replace(/\\/g, "/");
|
|
277
|
+
routePath = "/api" + (relDir ? `/${relDir}` : "");
|
|
278
|
+
} else if (style === "pages" || style === "nuxt-server") {
|
|
279
|
+
const relFile = path.relative(baseDir, full).replace(/\\/g, "/");
|
|
280
|
+
const withoutExt = relFile.replace(/\.[^.]+$/, "");
|
|
281
|
+
const cleaned = withoutExt.replace(/\/index$/, "") || "";
|
|
282
|
+
routePath = "/api" + (cleaned ? `/${cleaned}` : "");
|
|
283
|
+
} else if (style === "remix") {
|
|
284
|
+
const relFile = path.relative(baseDir, full).replace(/\\/g, "/");
|
|
285
|
+
const withoutExt = relFile.replace(/\.[^.]+$/, "");
|
|
286
|
+
const remixPath = withoutExt.replace(/\./g, "/").replace(/\$/g, "[param]").replace(/\/index$/, "");
|
|
287
|
+
if (!remixPath.startsWith("api")) continue;
|
|
288
|
+
routePath = `/${remixPath}`;
|
|
289
|
+
} else {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
routePath = routePath.replace(/\/\([^)]+\)/g, "");
|
|
293
|
+
const compiled = compileRoutePattern(routePath);
|
|
294
|
+
routes.push({
|
|
295
|
+
pattern: routePath,
|
|
296
|
+
regex: compiled.regex,
|
|
297
|
+
isDynamic: compiled.isDynamic,
|
|
298
|
+
isCatchAll: compiled.isCatchAll,
|
|
299
|
+
filePath: full
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function detectFramework(workspaceRoot) {
|
|
304
|
+
try {
|
|
305
|
+
const raw = fs.readFileSync(path.join(workspaceRoot, "package.json"), "utf-8");
|
|
306
|
+
const pkg = JSON.parse(raw);
|
|
307
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
308
|
+
if (allDeps["next"]) return "nextjs";
|
|
309
|
+
if (allDeps["nuxt"]) return "nuxt";
|
|
310
|
+
if (allDeps["@sveltejs/kit"]) return "sveltekit";
|
|
311
|
+
if (allDeps["@remix-run/node"] || allDeps["@remix-run/react"]) return "remix";
|
|
312
|
+
if (allDeps["express"]) return "express";
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (process.env.VIBECHECK_DEBUG) {
|
|
315
|
+
console.warn(
|
|
316
|
+
"[ghost_route] detectFramework failed:",
|
|
317
|
+
err instanceof Error ? err.message : err
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return "unknown";
|
|
322
|
+
}
|
|
323
|
+
var GhostRouteEngine = class {
|
|
324
|
+
id = "ghost_route";
|
|
325
|
+
_workspaceRoot;
|
|
326
|
+
_confidenceThreshold;
|
|
327
|
+
_framework = "unknown";
|
|
328
|
+
_routeIndex = null;
|
|
329
|
+
_indexBuilding = null;
|
|
330
|
+
/** Additional path prefixes to never flag. */
|
|
331
|
+
_extraSafePrefixes;
|
|
332
|
+
// Stats
|
|
333
|
+
_stats = {
|
|
334
|
+
filesScanned: 0,
|
|
335
|
+
filesSkipped: 0,
|
|
336
|
+
routeCallsDetected: 0,
|
|
337
|
+
ghostRoutesFound: 0,
|
|
338
|
+
routeIndexSize: 0,
|
|
339
|
+
avgScanMs: 0
|
|
340
|
+
};
|
|
341
|
+
_totalScanMs = 0;
|
|
342
|
+
constructor(workspaceRoot, confidenceThreshold = 0.75, extraSafePrefixes = [], truthpackRouteIndex) {
|
|
343
|
+
this._workspaceRoot = workspaceRoot;
|
|
344
|
+
this._confidenceThreshold = confidenceThreshold;
|
|
345
|
+
this._extraSafePrefixes = extraSafePrefixes;
|
|
346
|
+
if (truthpackRouteIndex) {
|
|
347
|
+
this._routeIndex = {
|
|
348
|
+
routes: truthpackRouteIndex.routes,
|
|
349
|
+
framework: "truthpack",
|
|
350
|
+
builtAt: Date.now()
|
|
351
|
+
};
|
|
352
|
+
this._stats.routeIndexSize = truthpackRouteIndex.routes.length;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
get stats() {
|
|
356
|
+
return { ...this._stats };
|
|
357
|
+
}
|
|
358
|
+
get framework() {
|
|
359
|
+
return this._framework;
|
|
360
|
+
}
|
|
361
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
362
|
+
async activate() {
|
|
363
|
+
this._framework = detectFramework(this._workspaceRoot);
|
|
364
|
+
await this._ensureIndex();
|
|
365
|
+
}
|
|
366
|
+
/** Rebuild route index. Call after file creation/deletion. */
|
|
367
|
+
async refresh() {
|
|
368
|
+
this._framework = detectFramework(this._workspaceRoot);
|
|
369
|
+
this._routeIndex = null;
|
|
370
|
+
this._indexBuilding = null;
|
|
371
|
+
await this._ensureIndex();
|
|
372
|
+
}
|
|
373
|
+
async _ensureIndex() {
|
|
374
|
+
if (this._routeIndex) return this._routeIndex;
|
|
375
|
+
if (this._indexBuilding) return this._indexBuilding;
|
|
376
|
+
this._indexBuilding = buildRouteIndex(this._workspaceRoot, this._framework).then((index) => {
|
|
377
|
+
this._routeIndex = index;
|
|
378
|
+
this._stats.routeIndexSize = index.routes.length;
|
|
379
|
+
this._indexBuilding = null;
|
|
380
|
+
return index;
|
|
381
|
+
});
|
|
382
|
+
return this._indexBuilding;
|
|
383
|
+
}
|
|
384
|
+
// ── Main Scan ────────────────────────────────────────────────────────────
|
|
385
|
+
async scan(delta, signal) {
|
|
386
|
+
if (signal?.aborted) return [];
|
|
387
|
+
const t0 = performance.now();
|
|
388
|
+
if (this._framework === "unknown") {
|
|
389
|
+
this._framework = detectFramework(this._workspaceRoot);
|
|
390
|
+
if (this._framework === "unknown") {
|
|
391
|
+
this._stats.filesSkipped++;
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!containsRouteCall(delta.fullText)) {
|
|
396
|
+
this._stats.filesSkipped++;
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
this._stats.filesScanned++;
|
|
400
|
+
const index = await this._ensureIndex();
|
|
401
|
+
if (signal?.aborted) return [];
|
|
402
|
+
const lines = delta.lines ?? delta.fullText.split("\n");
|
|
403
|
+
const regions = buildLineRegions(lines);
|
|
404
|
+
const calls = this._extractRouteCalls(lines, regions, signal);
|
|
405
|
+
this._stats.routeCallsDetected += calls.length;
|
|
406
|
+
const findings = [];
|
|
407
|
+
const seen = /* @__PURE__ */ new Set();
|
|
408
|
+
for (const call of calls) {
|
|
409
|
+
if (signal?.aborted) break;
|
|
410
|
+
if (seen.has(call.apiPath)) continue;
|
|
411
|
+
seen.add(call.apiPath);
|
|
412
|
+
if (isSafePath(call.apiPath)) continue;
|
|
413
|
+
if (this._extraSafePrefixes.some((p) => call.apiPath.startsWith(p))) continue;
|
|
414
|
+
if (this._matchesRoute(index, call.apiPath)) continue;
|
|
415
|
+
let confidence = 0.85;
|
|
416
|
+
if (call.hasInterpolation) confidence = 0.65;
|
|
417
|
+
if (confidence < this._confidenceThreshold) continue;
|
|
418
|
+
const suggestion = this._findClosestRoute(index, call.apiPath);
|
|
419
|
+
const sub = call.apiPath.replace(/^\/api\//, "");
|
|
420
|
+
this._stats.ghostRoutesFound++;
|
|
421
|
+
findings.push({
|
|
422
|
+
id: deterministicId(delta.documentUri, call.line, call.column, call.apiPath),
|
|
423
|
+
engine: this.id,
|
|
424
|
+
severity: "high",
|
|
425
|
+
category: "ghost_route",
|
|
426
|
+
file: delta.documentUri,
|
|
427
|
+
line: call.line,
|
|
428
|
+
column: call.column,
|
|
429
|
+
endLine: call.line,
|
|
430
|
+
endColumn: call.endColumn,
|
|
431
|
+
message: `Route \`${call.apiPath}\` has no handler file in this ${this._frameworkLabel()} project`,
|
|
432
|
+
evidence: call.evidence,
|
|
433
|
+
suggestion: suggestion ? `Did you mean \`${suggestion}\`? Or create \`${this._suggestFilePath(sub)}\`` : `Create \`${this._suggestFilePath(sub)}\``,
|
|
434
|
+
confidence,
|
|
435
|
+
autoFixable: false,
|
|
436
|
+
ruleId: "GHO001"
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const elapsed = performance.now() - t0;
|
|
440
|
+
this._totalScanMs += elapsed;
|
|
441
|
+
this._stats.avgScanMs = this._stats.filesScanned > 0 ? Math.round(this._totalScanMs / this._stats.filesScanned) : 0;
|
|
442
|
+
return findings;
|
|
443
|
+
}
|
|
444
|
+
// ── Route Call Extraction ────────────────────────────────────────────────
|
|
445
|
+
_extractRouteCalls(lines, regions, signal) {
|
|
446
|
+
const results = [];
|
|
447
|
+
for (let i = 0; i < lines.length; i++) {
|
|
448
|
+
if (signal?.aborted) break;
|
|
449
|
+
const region = regions[i];
|
|
450
|
+
if (region.inComment) continue;
|
|
451
|
+
const line = lines[i];
|
|
452
|
+
const codeStart = region.codeStart ?? 0;
|
|
453
|
+
const scanLine = codeStart > 0 ? line.slice(codeStart) : line;
|
|
454
|
+
for (const pattern of CALL_PATTERNS) {
|
|
455
|
+
pattern.regex.lastIndex = 0;
|
|
456
|
+
let m;
|
|
457
|
+
while ((m = pattern.regex.exec(scanLine)) !== null) {
|
|
458
|
+
const rawPath = m[pattern.pathGroup];
|
|
459
|
+
const apiPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
460
|
+
if (/^https?:\/\//.test(rawPath)) continue;
|
|
461
|
+
const hasInterpolation = INTERPOLATION_RE.test(m[0]);
|
|
462
|
+
results.push({
|
|
463
|
+
apiPath: normalizeApiPath(apiPath),
|
|
464
|
+
line: i + 1,
|
|
465
|
+
column: codeStart + m.index,
|
|
466
|
+
endColumn: codeStart + m.index + m[0].length,
|
|
467
|
+
evidence: m[0],
|
|
468
|
+
client: pattern.client,
|
|
469
|
+
hasInterpolation
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return results;
|
|
475
|
+
}
|
|
476
|
+
// ── Route Matching ───────────────────────────────────────────────────────
|
|
477
|
+
_matchesRoute(index, apiPath) {
|
|
478
|
+
for (const route of index.routes) {
|
|
479
|
+
if (route.pattern === apiPath) return true;
|
|
480
|
+
if (route.isDynamic && route.regex.test(apiPath)) return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
/** Find the closest existing route by Levenshtein distance. */
|
|
485
|
+
_findClosestRoute(index, apiPath) {
|
|
486
|
+
let best = null;
|
|
487
|
+
let bestDist = 5;
|
|
488
|
+
for (const route of index.routes) {
|
|
489
|
+
if (route.isDynamic) continue;
|
|
490
|
+
const d = levenshtein(apiPath, route.pattern);
|
|
491
|
+
if (d > 0 && d < bestDist) {
|
|
492
|
+
bestDist = d;
|
|
493
|
+
best = route.pattern;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return best;
|
|
497
|
+
}
|
|
498
|
+
// ── Suggestion Helpers ───────────────────────────────────────────────────
|
|
499
|
+
_suggestFilePath(sub) {
|
|
500
|
+
switch (this._framework) {
|
|
501
|
+
case "nextjs":
|
|
502
|
+
return `app/api/${sub}/route.ts`;
|
|
503
|
+
case "nuxt":
|
|
504
|
+
return `server/api/${sub}.ts`;
|
|
505
|
+
case "sveltekit":
|
|
506
|
+
return `src/routes/api/${sub}/+server.ts`;
|
|
507
|
+
case "remix":
|
|
508
|
+
return `app/routes/api.${sub.replace(/\//g, ".")}.ts`;
|
|
509
|
+
default:
|
|
510
|
+
return `app/api/${sub}/route.ts`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
_frameworkLabel() {
|
|
514
|
+
switch (this._framework) {
|
|
515
|
+
case "nextjs":
|
|
516
|
+
return "Next.js";
|
|
517
|
+
case "nuxt":
|
|
518
|
+
return "Nuxt";
|
|
519
|
+
case "sveltekit":
|
|
520
|
+
return "SvelteKit";
|
|
521
|
+
case "remix":
|
|
522
|
+
return "Remix";
|
|
523
|
+
case "express":
|
|
524
|
+
return "Express";
|
|
525
|
+
case "truthpack":
|
|
526
|
+
return "truthpack";
|
|
527
|
+
default:
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
532
|
+
dispose() {
|
|
533
|
+
this._routeIndex = null;
|
|
534
|
+
this._indexBuilding = null;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
function containsRouteCall(text) {
|
|
538
|
+
return text.includes("/api/") || text.includes("'api/") || text.includes('"api/') || text.includes("`/api") || // template literal routes
|
|
539
|
+
text.includes("fetch(") || // any fetch call may hit an API
|
|
540
|
+
text.includes("axios.") || // axios method calls
|
|
541
|
+
text.includes("$fetch(") || // Nuxt $fetch
|
|
542
|
+
text.includes(".get(") || // HTTP client .get/.post/.put/.delete
|
|
543
|
+
text.includes(".post(") || text.includes("/v1/") || // versioned API paths (v1, v2)
|
|
544
|
+
text.includes("/v2/");
|
|
545
|
+
}
|
|
546
|
+
function normalizeApiPath(p) {
|
|
547
|
+
return p.replace(/\/+/g, "/").replace(/\/$/, "") || // strip trailing slash
|
|
548
|
+
"/api";
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export { GhostRouteEngine };
|