@skyramp/mcp 0.0.59 → 0.0.60-rc.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 (35) hide show
  1. package/build/index.js +32 -5
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +98 -0
  3. package/build/prompts/test-recommendation/recommendationSections.js +226 -0
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +71 -0
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +166 -104
  6. package/build/prompts/testGenerationPrompt.js +2 -3
  7. package/build/prompts/testbot/testbot-prompts.js +96 -93
  8. package/build/resources/analysisResources.js +254 -0
  9. package/build/services/ScenarioGenerationService.js +70 -26
  10. package/build/tools/generate-tests/generateIntegrationRestTool.js +54 -1
  11. package/build/tools/generate-tests/generateScenarioRestTool.js +8 -5
  12. package/build/tools/submitReportTool.js +28 -0
  13. package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
  14. package/build/tools/test-recommendation/analyzeRepositoryTool.js +349 -217
  15. package/build/tools/test-recommendation/recommendTestsTool.js +163 -159
  16. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  17. package/build/types/RepositoryAnalysis.js +99 -12
  18. package/build/utils/AnalysisStateManager.js +40 -23
  19. package/build/utils/branchDiff.js +47 -0
  20. package/build/utils/pr-comment-parser.js +124 -0
  21. package/build/utils/projectMetadata.js +188 -0
  22. package/build/utils/projectMetadata.test.js +81 -0
  23. package/build/utils/repoScanner.js +378 -0
  24. package/build/utils/routeParsers.js +213 -0
  25. package/build/utils/routeParsers.test.js +87 -0
  26. package/build/utils/scenarioDrafting.js +119 -0
  27. package/build/utils/scenarioDrafting.test.js +66 -0
  28. package/build/utils/trace-parser.js +166 -0
  29. package/build/utils/workspaceAuth.js +16 -0
  30. package/package.json +1 -1
  31. package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
  32. package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
  33. package/build/tools/test-recommendation/mapTestsTool.js +0 -243
  34. package/build/types/TestMapping.js +0 -173
  35. package/build/utils/scoring-engine.js +0 -380
@@ -0,0 +1,378 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { nextjsFileToApiPath, parseRouteLine, extractEndpointsFromPythonFile, } from "./routeParsers.js";
4
+ function globRecursive(dir, extensions) {
5
+ const results = [];
6
+ let entries;
7
+ try {
8
+ entries = fs.readdirSync(dir, { withFileTypes: true });
9
+ }
10
+ catch {
11
+ return results;
12
+ }
13
+ for (const entry of entries) {
14
+ const fullPath = path.join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".next" || entry.name === "dist" || entry.name === "build")
17
+ continue;
18
+ results.push(...globRecursive(fullPath, extensions));
19
+ }
20
+ else if (extensions.some((ext) => entry.name.endsWith(ext))) {
21
+ results.push(fullPath);
22
+ }
23
+ }
24
+ return results;
25
+ }
26
+ export function detectApiPrefix(repositoryPath) {
27
+ const mainCandidates = ["main.py", "app.py", "server.py", "asgi.py", "wsgi.py"];
28
+ const mainFiles = [];
29
+ const searchDirs = [repositoryPath];
30
+ for (const sub of ["backend", "src", "app", "api"]) {
31
+ const subDir = path.join(repositoryPath, sub);
32
+ if (fs.existsSync(subDir))
33
+ searchDirs.push(subDir);
34
+ }
35
+ for (const dir of searchDirs) {
36
+ const files = globRecursive(dir, [".py"]);
37
+ for (const f of files) {
38
+ if (mainCandidates.includes(path.basename(f)) && !/test/i.test(f)) {
39
+ mainFiles.push(f);
40
+ }
41
+ }
42
+ }
43
+ for (const mainFile of mainFiles) {
44
+ let fc;
45
+ try {
46
+ fc = fs.readFileSync(mainFile, "utf-8");
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (!fc.includes("include_router"))
52
+ continue;
53
+ const appVarMatch = fc.match(/(\w+)\s*=\s*(?:FastAPI|Flask)\s*\(/);
54
+ const appVar = appVarMatch ? appVarMatch[1] : "app";
55
+ const includePattern = new RegExp(appVar + "\\.include_router\\s*\\([^)]*prefix\\s*=\\s*(?:" +
56
+ '["' + "'" + ']([^"' + "'" + ']+)["' + "'" + ']' +
57
+ "|(\\w+)" +
58
+ ")", "g");
59
+ let match;
60
+ while ((match = includePattern.exec(fc)) !== null) {
61
+ if (match[1]) {
62
+ const prefix = match[1];
63
+ if (!prefix.includes("{"))
64
+ return prefix;
65
+ continue;
66
+ }
67
+ const varName = match[2];
68
+ if (!varName)
69
+ continue;
70
+ const simpleRe = new RegExp(varName + "\\s*=\\s*[\"']([^\"']+)[\"']");
71
+ const simpleMatch = fc.match(simpleRe);
72
+ if (simpleMatch && !simpleMatch[1].includes("{"))
73
+ return simpleMatch[1];
74
+ const fstringRe = new RegExp(varName + "\\s*=\\s*f[\"']([^\"']+)[\"']");
75
+ const fstringMatch = fc.match(fstringRe);
76
+ if (fstringMatch) {
77
+ let prefix = fstringMatch[1];
78
+ const envFiles = [".env", "backend/.env", ".env.local", "backend/src/.env"];
79
+ for (const envFile of envFiles) {
80
+ try {
81
+ const envContent = fs.readFileSync(path.join(repositoryPath, envFile), "utf-8");
82
+ const varRefs = prefix.match(/\{[^}]+\}/g) || [];
83
+ for (const vr of varRefs) {
84
+ const keyName = vr.replace(/[{}]/g, "").split(".").pop() || "";
85
+ if (!keyName)
86
+ continue;
87
+ const envLine = envContent.match(new RegExp("^" + keyName + "\\s*=\\s*(.+)$", "m"));
88
+ if (envLine) {
89
+ prefix = prefix.replace(vr, envLine[1].trim());
90
+ }
91
+ }
92
+ }
93
+ catch { /* env file not found */ }
94
+ }
95
+ prefix = prefix.replace(/\{[^}]*VERSION[^}]*\}/gi, "v1");
96
+ prefix = prefix.replace(/\{[^}]+\}/g, "");
97
+ prefix = prefix.replace(/\/\//g, "/").replace(/\/$/, "");
98
+ if (prefix && prefix !== "/" && !prefix.includes("{"))
99
+ return prefix;
100
+ }
101
+ }
102
+ }
103
+ const jsMainCandidates = ["app.ts", "app.js", "server.ts", "server.js", "index.ts", "index.js"];
104
+ for (const dir of searchDirs) {
105
+ const files = globRecursive(dir, [".ts", ".js"]);
106
+ for (const f of files) {
107
+ if (!jsMainCandidates.includes(path.basename(f)))
108
+ continue;
109
+ let fc;
110
+ try {
111
+ fc = fs.readFileSync(f, "utf-8");
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ const expressMatch = fc.match(/app\.use\s*\(\s*["'](\/[^"']+)["']\s*,/);
117
+ if (expressMatch && expressMatch[1].length > 1)
118
+ return expressMatch[1];
119
+ }
120
+ }
121
+ // Go (Gin): r := gin.Default(); api := r.Group("/api/v1")
122
+ const goMainCandidates = ["main.go", "server.go", "app.go", "router.go", "routes.go"];
123
+ for (const dir of searchDirs) {
124
+ const files = globRecursive(dir, [".go"]);
125
+ for (const f of files) {
126
+ if (!goMainCandidates.includes(path.basename(f)))
127
+ continue;
128
+ let fc;
129
+ try {
130
+ fc = fs.readFileSync(f, "utf-8");
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ const ginGroup = fc.match(/\.Group\s*\(\s*["'](\/[^"']+)["']/);
136
+ if (ginGroup && ginGroup[1].length > 1)
137
+ return ginGroup[1];
138
+ }
139
+ }
140
+ // Ruby on Rails: namespace :api do / scope "/api/v1" do
141
+ for (const dir of searchDirs) {
142
+ const files = globRecursive(dir, [".rb"]);
143
+ for (const f of files) {
144
+ if (!/routes/i.test(path.basename(f)))
145
+ continue;
146
+ let fc;
147
+ try {
148
+ fc = fs.readFileSync(f, "utf-8");
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ const scopeMatch = fc.match(/scope\s+["'](\/[^"']+)["']/);
154
+ if (scopeMatch && scopeMatch[1].length > 1)
155
+ return scopeMatch[1];
156
+ }
157
+ }
158
+ // PHP Laravel: Route::prefix('/api/v1')->group(...)
159
+ for (const dir of searchDirs) {
160
+ const files = globRecursive(dir, [".php"]);
161
+ for (const f of files) {
162
+ if (!/route|api/i.test(path.basename(f)))
163
+ continue;
164
+ let fc;
165
+ try {
166
+ fc = fs.readFileSync(f, "utf-8");
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ const prefixMatch = fc.match(/Route::prefix\s*\(\s*["'](\/[^"']+)["']/);
172
+ if (prefixMatch && prefixMatch[1].length > 1)
173
+ return prefixMatch[1];
174
+ }
175
+ }
176
+ return "";
177
+ }
178
+ export function grepRouterMountingContext(repositoryPath) {
179
+ const entryCandidates = [
180
+ "main.py", "app.py", "server.py", "asgi.py", "wsgi.py",
181
+ "app.ts", "app.js", "server.ts", "server.js", "index.ts", "index.js",
182
+ "urls.py",
183
+ "main.go", "server.go", "router.go", "routes.go",
184
+ "routes.rb", "web.php", "api.php",
185
+ ];
186
+ const searchDirs = [repositoryPath];
187
+ for (const sub of ["backend", "src", "app", "api"]) {
188
+ const subDir = path.join(repositoryPath, sub);
189
+ if (fs.existsSync(subDir))
190
+ searchDirs.push(subDir);
191
+ }
192
+ const mountPatterns = [
193
+ /include_router/i,
194
+ /register_blueprint/i,
195
+ /\.use\s*\(\s*["']\//,
196
+ /urlpatterns/,
197
+ /path\s*\(.+include\s*\(/,
198
+ /\.Group\s*\(/,
199
+ /Route::prefix/,
200
+ /namespace\s+:api/,
201
+ /scope\s+["']\//,
202
+ ];
203
+ const matches = [];
204
+ for (const dir of searchDirs) {
205
+ const files = globRecursive(dir, [".py", ".ts", ".js", ".go", ".rb", ".php", ".rs"]);
206
+ for (const f of files) {
207
+ if (!entryCandidates.includes(path.basename(f)))
208
+ continue;
209
+ if (/test/i.test(f))
210
+ continue;
211
+ let fc;
212
+ try {
213
+ fc = fs.readFileSync(f, "utf-8");
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ const hasMounting = mountPatterns.some((p) => p.test(fc));
219
+ if (!hasMounting)
220
+ continue;
221
+ const relPath = f.startsWith(repositoryPath)
222
+ ? f.slice(repositoryPath.length + 1)
223
+ : f;
224
+ const relevantLines = [];
225
+ for (const line of fc.split("\n")) {
226
+ const trimmed = line.trim();
227
+ if (/include_router|register_blueprint/i.test(trimmed) ||
228
+ /\.use\s*\(\s*["']\//.test(trimmed) ||
229
+ /path\s*\(.+include\s*\(/.test(trimmed) ||
230
+ /^from\s+.+import\s+.*router/i.test(trimmed) ||
231
+ /^import\s+.+router/i.test(trimmed) ||
232
+ /^const\s+\w+Router\s*=\s*require/i.test(trimmed) ||
233
+ /^import\s+\w+Router\s+from/i.test(trimmed) ||
234
+ /\.Group\s*\(/.test(trimmed) ||
235
+ /Route::prefix/i.test(trimmed) ||
236
+ /namespace\s+:/.test(trimmed) ||
237
+ /scope\s+["']\//.test(trimmed)) {
238
+ relevantLines.push(trimmed);
239
+ }
240
+ }
241
+ if (relevantLines.length > 0) {
242
+ matches.push(`${relPath}:\n${relevantLines.map((l) => " " + l).join("\n")}`);
243
+ }
244
+ }
245
+ }
246
+ return matches.join("\n\n");
247
+ }
248
+ function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath) {
249
+ const relative = sourceFile.startsWith(repositoryPath)
250
+ ? sourceFile.slice(repositoryPath.length + 1) : sourceFile;
251
+ const existing = endpointMap.get(apiPath);
252
+ if (existing)
253
+ existing.methods.add(method);
254
+ else
255
+ endpointMap.set(apiPath, { methods: new Set([method]), sourceFile: relative });
256
+ }
257
+ function scanNextjsFile(file, repositoryPath, endpointMap) {
258
+ const relative = file.startsWith(repositoryPath)
259
+ ? file.slice(repositoryPath.length + 1) : file;
260
+ const apiPath = nextjsFileToApiPath(relative);
261
+ if (!apiPath)
262
+ return false;
263
+ let content;
264
+ try {
265
+ content = fs.readFileSync(file, "utf-8");
266
+ }
267
+ catch {
268
+ addEndpointToMap(endpointMap, apiPath, "MULTI", file, repositoryPath);
269
+ return true;
270
+ }
271
+ const detectedMethods = new Set();
272
+ for (const line of content.split("\n")) {
273
+ const mc = line.match(/req\.method\s*===?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
274
+ if (mc)
275
+ detectedMethods.add(mc[1].toUpperCase());
276
+ const em = line.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
277
+ if (em)
278
+ detectedMethods.add(em[1].toUpperCase());
279
+ }
280
+ if (detectedMethods.size > 0) {
281
+ for (const m of detectedMethods)
282
+ addEndpointToMap(endpointMap, apiPath, m, file, repositoryPath);
283
+ }
284
+ else {
285
+ addEndpointToMap(endpointMap, apiPath, "MULTI", file, repositoryPath);
286
+ }
287
+ return true;
288
+ }
289
+ export function scanAllRepoEndpoints(repositoryPath) {
290
+ const endpointMap = new Map();
291
+ const appPrefix = detectApiPrefix(repositoryPath);
292
+ const sourceFiles = globRecursive(repositoryPath, [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".kt", ".go", ".rb", ".php", ".rs", ".cs"]);
293
+ for (const file of sourceFiles) {
294
+ if (scanNextjsFile(file, repositoryPath, endpointMap))
295
+ continue;
296
+ const relative = file.startsWith(repositoryPath)
297
+ ? file.slice(repositoryPath.length + 1) : file;
298
+ if (!/route|controller|endpoint|handler|view|urls|api|router/i.test(relative))
299
+ continue;
300
+ let content;
301
+ try {
302
+ content = fs.readFileSync(file, "utf-8");
303
+ }
304
+ catch {
305
+ continue;
306
+ }
307
+ if (file.endsWith(".py")) {
308
+ const pyEndpoints = extractEndpointsFromPythonFile(content, relative);
309
+ for (const ep of pyEndpoints) {
310
+ const fullPath = appPrefix && !ep.path.startsWith(appPrefix) ? appPrefix + ep.path : ep.path;
311
+ addEndpointToMap(endpointMap, fullPath, ep.method, file, repositoryPath);
312
+ }
313
+ }
314
+ else {
315
+ for (const line of content.split("\n")) {
316
+ const ep = parseRouteLine(line, relative);
317
+ if (ep)
318
+ addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
319
+ }
320
+ }
321
+ }
322
+ return Array.from(endpointMap.entries()).map(([apiPath, data]) => ({
323
+ path: apiPath,
324
+ methods: Array.from(data.methods),
325
+ sourceFile: data.sourceFile,
326
+ }));
327
+ }
328
+ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
329
+ const appPrefix = detectApiPrefix(repositoryPath);
330
+ const relatedDirs = new Set();
331
+ for (const f of changedFiles) {
332
+ const absDir = path.dirname(path.join(repositoryPath, f));
333
+ relatedDirs.add(absDir);
334
+ const parentDir = path.dirname(absDir);
335
+ if (parentDir !== repositoryPath)
336
+ relatedDirs.add(parentDir);
337
+ }
338
+ const endpointMap = new Map();
339
+ for (const dir of relatedDirs) {
340
+ if (!fs.existsSync(dir))
341
+ continue;
342
+ const files = globRecursive(dir, [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".kt", ".go", ".rb", ".php", ".rs", ".cs"]);
343
+ for (const file of files) {
344
+ if (scanNextjsFile(file, repositoryPath, endpointMap))
345
+ continue;
346
+ const relative = file.startsWith(repositoryPath)
347
+ ? file.slice(repositoryPath.length + 1) : file;
348
+ if (!/route|controller|endpoint|handler|view|urls|api|router/i.test(relative))
349
+ continue;
350
+ let fileContent;
351
+ try {
352
+ fileContent = fs.readFileSync(file, "utf-8");
353
+ }
354
+ catch {
355
+ continue;
356
+ }
357
+ if (file.endsWith(".py")) {
358
+ const pyEndpoints = extractEndpointsFromPythonFile(fileContent, relative);
359
+ for (const ep of pyEndpoints) {
360
+ const fullPath = appPrefix && !ep.path.startsWith(appPrefix) ? appPrefix + ep.path : ep.path;
361
+ addEndpointToMap(endpointMap, fullPath, ep.method, file, repositoryPath);
362
+ }
363
+ }
364
+ else {
365
+ for (const line of fileContent.split("\n")) {
366
+ const ep = parseRouteLine(line, relative);
367
+ if (ep)
368
+ addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ return Array.from(endpointMap.entries()).map(([apiPath, data]) => ({
374
+ path: apiPath,
375
+ methods: Array.from(data.methods),
376
+ sourceFile: data.sourceFile,
377
+ }));
378
+ }
@@ -0,0 +1,213 @@
1
+ export function nextjsFileToApiPath(filePath) {
2
+ const pagesMatch = filePath.match(/(?:^|\/)pages\/(api\/.+)\.[jt]sx?$/);
3
+ if (pagesMatch) {
4
+ let route = "/" + pagesMatch[1];
5
+ route = route.replace(/\/index$/, "");
6
+ route = route
7
+ .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
8
+ .replace(/\[(\w+)\]/g, "{$1}");
9
+ return route;
10
+ }
11
+ const appMatch = filePath.match(/(?:^|\/)app\/(api\/.+)\/route\.[jt]sx?$/);
12
+ if (appMatch) {
13
+ let route = "/" + appMatch[1];
14
+ route = route
15
+ .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
16
+ .replace(/\[(\w+)\]/g, "{$1}");
17
+ return route;
18
+ }
19
+ return null;
20
+ }
21
+ export function parseRouteLine(line, sourceFile) {
22
+ const stripped = line.replace(/^[+-]\s*/, "").trim();
23
+ const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]*)/i);
24
+ if (decoratorMatch) {
25
+ return {
26
+ method: decoratorMatch[1].toUpperCase(),
27
+ path: decoratorMatch[2],
28
+ sourceFile,
29
+ };
30
+ }
31
+ const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]+)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
32
+ if (flaskMatch) {
33
+ return {
34
+ method: flaskMatch[2].toUpperCase(),
35
+ path: flaskMatch[1],
36
+ sourceFile,
37
+ };
38
+ }
39
+ const expressMatch = stripped.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
40
+ if (expressMatch) {
41
+ return {
42
+ method: expressMatch[1].toUpperCase(),
43
+ path: expressMatch[2],
44
+ sourceFile,
45
+ };
46
+ }
47
+ const springMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)Mapping\s*(?:\(\s*(?:value\s*=\s*)?["']([^"'?#]+)["'])?/i);
48
+ if (springMatch && springMatch[2]) {
49
+ return {
50
+ method: springMatch[1].toUpperCase(),
51
+ path: springMatch[2],
52
+ sourceFile,
53
+ };
54
+ }
55
+ const nestjsMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"'?#]+)["']/i);
56
+ if (nestjsMatch) {
57
+ return {
58
+ method: nestjsMatch[1].toUpperCase(),
59
+ path: nestjsMatch[2],
60
+ sourceFile,
61
+ };
62
+ }
63
+ const nextjsMethodMatch = stripped.match(/req\.method\s*===?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
64
+ if (nextjsMethodMatch) {
65
+ const apiPath = nextjsFileToApiPath(sourceFile);
66
+ if (apiPath) {
67
+ return {
68
+ method: nextjsMethodMatch[1].toUpperCase(),
69
+ path: apiPath,
70
+ sourceFile,
71
+ };
72
+ }
73
+ }
74
+ const appRouterExportMatch = stripped.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
75
+ if (appRouterExportMatch) {
76
+ const apiPath = nextjsFileToApiPath(sourceFile);
77
+ if (apiPath) {
78
+ return {
79
+ method: appRouterExportMatch[1].toUpperCase(),
80
+ path: apiPath,
81
+ sourceFile,
82
+ };
83
+ }
84
+ }
85
+ // Go (Gin, Echo, Chi): <ident>.GET/POST/...("/path", handler)
86
+ const ginMatch = stripped.match(/(?:\w+)\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/i);
87
+ if (ginMatch && sourceFile.endsWith(".go")) {
88
+ return { method: ginMatch[1].toUpperCase(), path: ginMatch[2], sourceFile };
89
+ }
90
+ // Go stdlib: http.HandleFunc/Handle
91
+ const goHandleMatch = stripped.match(/(?:HandleFunc|Handle)\s*\(\s*["']([^"'?#]+)/);
92
+ if (goHandleMatch && sourceFile.endsWith(".go")) {
93
+ return { method: "MULTI", path: goHandleMatch[1], sourceFile };
94
+ }
95
+ // Ruby — Rails
96
+ const railsMatch = stripped.match(/^(get|post|put|patch|delete)\s+["']([^"'?#]+)/i);
97
+ if (railsMatch && (sourceFile.endsWith(".rb") || /routes/i.test(sourceFile))) {
98
+ return { method: railsMatch[1].toUpperCase(), path: railsMatch[2], sourceFile };
99
+ }
100
+ // PHP — Laravel
101
+ const laravelMatch = stripped.match(/Route::(get|post|put|patch|delete|options)\s*\(\s*["']([^"'?#]+)/i);
102
+ if (laravelMatch) {
103
+ return { method: laravelMatch[1].toUpperCase(), path: laravelMatch[2], sourceFile };
104
+ }
105
+ // Rust — Actix
106
+ const actixAttrMatch = stripped.match(/#\[(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
107
+ if (actixAttrMatch) {
108
+ return { method: actixAttrMatch[1].toUpperCase(), path: actixAttrMatch[2], sourceFile };
109
+ }
110
+ // ASP.NET
111
+ const aspnetMatch = stripped.match(/\[Http(Get|Post|Put|Patch|Delete)\s*(?:\(\s*["']([^"'?#]+)["'])?/i);
112
+ if (aspnetMatch) {
113
+ return {
114
+ method: aspnetMatch[1].toUpperCase(),
115
+ path: aspnetMatch[2] || "/",
116
+ sourceFile,
117
+ };
118
+ }
119
+ // Hapi.js
120
+ const hapiMatch = stripped.match(/method:\s*["'](GET|POST|PUT|PATCH|DELETE)["'][^}]*path:\s*["']([^"'?#]+)/i);
121
+ if (hapiMatch) {
122
+ return { method: hapiMatch[1].toUpperCase(), path: hapiMatch[2], sourceFile };
123
+ }
124
+ return null;
125
+ }
126
+ export function extractEndpointsFromPythonFile(content, sourceFile) {
127
+ const endpoints = [];
128
+ let routerPrefix = "";
129
+ const fastapiPrefix = content.match(/APIRouter\s*\([^)]*prefix\s*=\s*["']([^"']+)["']/);
130
+ if (fastapiPrefix) {
131
+ routerPrefix = fastapiPrefix[1];
132
+ }
133
+ else {
134
+ const flaskPrefix = content.match(/Blueprint\s*\([^)]*url_prefix\s*=\s*["']([^"']+)["']/);
135
+ if (flaskPrefix)
136
+ routerPrefix = flaskPrefix[1];
137
+ }
138
+ for (const line of content.split("\n")) {
139
+ const stripped = line.trim();
140
+ const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]*)["']/i);
141
+ if (decoratorMatch) {
142
+ const method = decoratorMatch[1].toUpperCase();
143
+ const routePath = decoratorMatch[2];
144
+ const fullPath = routerPrefix + routePath;
145
+ if (fullPath)
146
+ endpoints.push({ method, path: fullPath });
147
+ continue;
148
+ }
149
+ const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]*)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
150
+ if (flaskMatch) {
151
+ const fpath = routerPrefix + flaskMatch[1];
152
+ if (fpath)
153
+ endpoints.push({ method: flaskMatch[2].toUpperCase(), path: fpath });
154
+ }
155
+ }
156
+ return endpoints;
157
+ }
158
+ export function parseEndpointsFromDiff(diffData) {
159
+ const lines = diffData.diffContent.split("\n");
160
+ const addedRoutes = [];
161
+ const removedKeys = new Set();
162
+ let currentFile = "";
163
+ for (const line of lines) {
164
+ const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
165
+ if (fileMatch) {
166
+ currentFile = fileMatch[1];
167
+ continue;
168
+ }
169
+ if (line.startsWith("+") && !line.startsWith("+++")) {
170
+ const ep = parseRouteLine(line, currentFile);
171
+ if (ep)
172
+ addedRoutes.push(ep);
173
+ }
174
+ else if (line.startsWith("-") && !line.startsWith("---")) {
175
+ const ep = parseRouteLine(line, currentFile);
176
+ if (ep)
177
+ removedKeys.add(`${ep.method} ${ep.path}`);
178
+ }
179
+ }
180
+ const addedRouteKeys = new Set(addedRoutes.map((r) => r.path));
181
+ for (const file of diffData.changedFiles) {
182
+ const apiPath = nextjsFileToApiPath(file);
183
+ if (apiPath && !addedRouteKeys.has(apiPath)) {
184
+ addedRoutes.push({ method: "MULTI", path: apiPath, sourceFile: file });
185
+ addedRouteKeys.add(apiPath);
186
+ }
187
+ }
188
+ const newEndpoints = [];
189
+ const modifiedEndpoints = [];
190
+ for (const ep of addedRoutes) {
191
+ if (removedKeys.has(`${ep.method} ${ep.path}`)) {
192
+ modifiedEndpoints.push(ep);
193
+ }
194
+ else {
195
+ newEndpoints.push(ep);
196
+ }
197
+ }
198
+ const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
199
+ const affectedServices = [
200
+ ...new Set(diffData.changedFiles
201
+ .map((f) => f.match(servicePattern)?.[1])
202
+ .filter((s) => !!s)),
203
+ ];
204
+ return {
205
+ currentBranch: diffData.currentBranch,
206
+ baseBranch: diffData.baseBranch,
207
+ changedFiles: diffData.changedFiles,
208
+ diffStat: diffData.diffStat,
209
+ newEndpoints,
210
+ modifiedEndpoints,
211
+ affectedServices,
212
+ };
213
+ }
@@ -0,0 +1,87 @@
1
+ // @ts-ignore
2
+ import { nextjsFileToApiPath, parseRouteLine } from "./routeParsers.js";
3
+ describe("nextjsFileToApiPath", () => {
4
+ it("converts pages/api route to API path", () => {
5
+ expect(nextjsFileToApiPath("pages/api/users/index.ts")).toBe("/api/users");
6
+ });
7
+ it("converts pages/api nested dynamic route", () => {
8
+ expect(nextjsFileToApiPath("pages/api/users/[id].ts")).toBe("/api/users/{id}");
9
+ });
10
+ it("converts app/api route (App Router)", () => {
11
+ expect(nextjsFileToApiPath("app/api/products/route.ts")).toBe("/api/products");
12
+ });
13
+ it("converts app/api nested dynamic route", () => {
14
+ expect(nextjsFileToApiPath("app/api/products/[id]/route.ts")).toBe("/api/products/{id}");
15
+ });
16
+ it("returns null for non-API paths", () => {
17
+ expect(nextjsFileToApiPath("src/components/Button.tsx")).toBeNull();
18
+ });
19
+ });
20
+ describe("parseRouteLine", () => {
21
+ it("parses FastAPI decorator", () => {
22
+ const result = parseRouteLine('@app.get("/users")', "main.py");
23
+ expect(result).toEqual({ method: "GET", path: "/users", sourceFile: "main.py" });
24
+ });
25
+ it("parses FastAPI with router prefix", () => {
26
+ const result = parseRouteLine('@router.post("/items")', "routes.py");
27
+ expect(result).toEqual({ method: "POST", path: "/items", sourceFile: "routes.py" });
28
+ });
29
+ it("parses Flask decorator", () => {
30
+ const result = parseRouteLine('@app.route("/health", methods=["GET"])', "app.py");
31
+ expect(result).toEqual({ method: "GET", path: "/health", sourceFile: "app.py" });
32
+ });
33
+ it("parses Express route", () => {
34
+ const result = parseRouteLine('router.get("/api/products", handler)', "routes.ts");
35
+ expect(result).toEqual({ method: "GET", path: "/api/products", sourceFile: "routes.ts" });
36
+ });
37
+ it("parses NestJS decorator", () => {
38
+ const result = parseRouteLine('@Get("users/:id")', "users.controller.ts");
39
+ expect(result).toEqual({ method: "GET", path: "users/:id", sourceFile: "users.controller.ts" });
40
+ });
41
+ it("parses Spring annotation", () => {
42
+ const result = parseRouteLine('@GetMapping("/api/v1/orders")', "OrderController.java");
43
+ expect(result).toEqual({ method: "GET", path: "/api/v1/orders", sourceFile: "OrderController.java" });
44
+ });
45
+ it("parses Next.js App Router export", () => {
46
+ const result = parseRouteLine("export async function GET(request: Request) {", "app/api/items/route.ts");
47
+ expect(result).not.toBeNull();
48
+ expect(result.method).toBe("GET");
49
+ });
50
+ it("parses Gin route", () => {
51
+ const result = parseRouteLine('r.GET("/users", getUsers)', "main.go");
52
+ expect(result).toEqual({ method: "GET", path: "/users", sourceFile: "main.go" });
53
+ });
54
+ it("parses Gin group route", () => {
55
+ const result = parseRouteLine('api.POST("/products", createProduct)', "router.go");
56
+ expect(result).toEqual({ method: "POST", path: "/products", sourceFile: "router.go" });
57
+ });
58
+ it("parses Rails route", () => {
59
+ const result = parseRouteLine('get "/users", to: "users#index"', "routes.rb");
60
+ expect(result).toEqual({ method: "GET", path: "/users", sourceFile: "routes.rb" });
61
+ });
62
+ it("parses Laravel route", () => {
63
+ const result = parseRouteLine("Route::get('/api/users', [UserController::class, 'index'])", "api.php");
64
+ expect(result).toEqual({ method: "GET", path: "/api/users", sourceFile: "api.php" });
65
+ });
66
+ it("parses Actix attribute", () => {
67
+ const result = parseRouteLine('#[get("/health")]', "main.rs");
68
+ expect(result).toEqual({ method: "GET", path: "/health", sourceFile: "main.rs" });
69
+ });
70
+ it("parses ASP.NET HttpGet", () => {
71
+ const result = parseRouteLine('[HttpGet("users/{id}")]', "UsersController.cs");
72
+ expect(result).toEqual({ method: "GET", path: "users/{id}", sourceFile: "UsersController.cs" });
73
+ });
74
+ it("parses ASP.NET HttpPost without path", () => {
75
+ const result = parseRouteLine("[HttpPost]", "OrdersController.cs");
76
+ expect(result).toEqual({ method: "POST", path: "/", sourceFile: "OrdersController.cs" });
77
+ });
78
+ it("parses Hapi route", () => {
79
+ const result = parseRouteLine("{ method: 'GET', path: '/api/users' }", "server.js");
80
+ expect(result).toEqual({ method: "GET", path: "/api/users", sourceFile: "server.js" });
81
+ });
82
+ it("returns null for non-route lines", () => {
83
+ expect(parseRouteLine("const x = 42;", "app.ts")).toBeNull();
84
+ expect(parseRouteLine("// comment", "main.py")).toBeNull();
85
+ expect(parseRouteLine("import express from 'express';", "index.ts")).toBeNull();
86
+ });
87
+ });