@skyramp/mcp 0.0.61 → 0.0.62
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/build/index.js +44 -6
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +101 -0
- package/build/prompts/test-recommendation/recommendationSections.js +193 -0
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +65 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +165 -99
- package/build/prompts/testGenerationPrompt.js +2 -3
- package/build/prompts/testbot/testbot-prompts.js +116 -96
- package/build/resources/analysisResources.js +248 -0
- package/build/services/ScenarioGenerationService.js +38 -40
- package/build/services/TestExecutionService.js +10 -1
- package/build/tools/generate-tests/generateScenarioRestTool.js +18 -6
- package/build/tools/submitReportTool.js +28 -0
- package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +386 -217
- package/build/tools/test-recommendation/recommendTestsTool.js +162 -163
- package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
- package/build/types/RepositoryAnalysis.js +100 -12
- package/build/utils/AnalysisStateManager.js +56 -23
- package/build/utils/branchDiff.js +47 -0
- package/build/utils/initAgent.js +62 -26
- package/build/utils/pr-comment-parser.js +124 -0
- package/build/utils/projectMetadata.js +188 -0
- package/build/utils/projectMetadata.test.js +81 -0
- package/build/utils/repoScanner.js +425 -0
- package/build/utils/routeParsers.js +213 -0
- package/build/utils/routeParsers.test.js +87 -0
- package/build/utils/scenarioDrafting.js +119 -0
- package/build/utils/scenarioDrafting.test.js +66 -0
- package/build/utils/skyrampMdContent.js +100 -0
- package/build/utils/trace-parser.js +166 -0
- package/build/utils/workspaceAuth.js +16 -0
- package/package.json +2 -2
- package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
- package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
- package/build/tools/test-recommendation/mapTestsTool.js +0 -243
- package/build/types/TestMapping.js +0 -173
- package/build/utils/scoring-engine.js +0 -380
|
@@ -0,0 +1,425 @@
|
|
|
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
|
+
// Java (Spring Boot): @RequestMapping prefix on controllers or config classes
|
|
141
|
+
for (const dir of searchDirs) {
|
|
142
|
+
const files = globRecursive(dir, [".java"]);
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
if (!/controller|config|application/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 requestMapping = fc.match(/@RequestMapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["']/);
|
|
154
|
+
if (requestMapping && requestMapping[1].length > 1)
|
|
155
|
+
return requestMapping[1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Java: check application.properties/yml for context-path (including Maven src/main/resources)
|
|
159
|
+
const propSearchDirs = [...searchDirs];
|
|
160
|
+
for (const sub of ["src/main/resources", "config"]) {
|
|
161
|
+
const subDir = path.join(repositoryPath, sub);
|
|
162
|
+
if (fs.existsSync(subDir))
|
|
163
|
+
propSearchDirs.push(subDir);
|
|
164
|
+
}
|
|
165
|
+
for (const dir of propSearchDirs) {
|
|
166
|
+
for (const propFile of ["application.properties", "application.yml", "application.yaml"]) {
|
|
167
|
+
const full = path.join(dir, propFile);
|
|
168
|
+
if (!fs.existsSync(full))
|
|
169
|
+
continue;
|
|
170
|
+
let fc;
|
|
171
|
+
try {
|
|
172
|
+
fc = fs.readFileSync(full, "utf-8");
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Flat format: server.servlet.context-path=/api/v1
|
|
178
|
+
const flatMatch = fc.match(/server\.servlet\.context-path\s*[=:]\s*(\S+)/);
|
|
179
|
+
if (flatMatch && flatMatch[1].length > 1)
|
|
180
|
+
return flatMatch[1];
|
|
181
|
+
// YAML nested format: context-path: /api/v1 (indented under server.servlet)
|
|
182
|
+
const yamlMatch = fc.match(/context-path\s*:\s*(\S+)/);
|
|
183
|
+
if (yamlMatch && yamlMatch[1].startsWith("/") && yamlMatch[1].length > 1)
|
|
184
|
+
return yamlMatch[1];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Ruby on Rails: namespace :api do / scope "/api/v1" do
|
|
188
|
+
for (const dir of searchDirs) {
|
|
189
|
+
const files = globRecursive(dir, [".rb"]);
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
if (!/routes/i.test(path.basename(f)))
|
|
192
|
+
continue;
|
|
193
|
+
let fc;
|
|
194
|
+
try {
|
|
195
|
+
fc = fs.readFileSync(f, "utf-8");
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const scopeMatch = fc.match(/scope\s+["'](\/[^"']+)["']/);
|
|
201
|
+
if (scopeMatch && scopeMatch[1].length > 1)
|
|
202
|
+
return scopeMatch[1];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// PHP Laravel: Route::prefix('/api/v1')->group(...)
|
|
206
|
+
for (const dir of searchDirs) {
|
|
207
|
+
const files = globRecursive(dir, [".php"]);
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
if (!/route|api/i.test(path.basename(f)))
|
|
210
|
+
continue;
|
|
211
|
+
let fc;
|
|
212
|
+
try {
|
|
213
|
+
fc = fs.readFileSync(f, "utf-8");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const prefixMatch = fc.match(/Route::prefix\s*\(\s*["'](\/[^"']+)["']/);
|
|
219
|
+
if (prefixMatch && prefixMatch[1].length > 1)
|
|
220
|
+
return prefixMatch[1];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
export function grepRouterMountingContext(repositoryPath) {
|
|
226
|
+
const entryCandidates = [
|
|
227
|
+
"main.py", "app.py", "server.py", "asgi.py", "wsgi.py",
|
|
228
|
+
"app.ts", "app.js", "server.ts", "server.js", "index.ts", "index.js",
|
|
229
|
+
"urls.py",
|
|
230
|
+
"main.go", "server.go", "router.go", "routes.go",
|
|
231
|
+
"routes.rb", "web.php", "api.php",
|
|
232
|
+
];
|
|
233
|
+
const searchDirs = [repositoryPath];
|
|
234
|
+
for (const sub of ["backend", "src", "app", "api"]) {
|
|
235
|
+
const subDir = path.join(repositoryPath, sub);
|
|
236
|
+
if (fs.existsSync(subDir))
|
|
237
|
+
searchDirs.push(subDir);
|
|
238
|
+
}
|
|
239
|
+
const mountPatterns = [
|
|
240
|
+
/include_router/i,
|
|
241
|
+
/register_blueprint/i,
|
|
242
|
+
/\.use\s*\(\s*["']\//,
|
|
243
|
+
/urlpatterns/,
|
|
244
|
+
/path\s*\(.+include\s*\(/,
|
|
245
|
+
/\.Group\s*\(/,
|
|
246
|
+
/Route::prefix/,
|
|
247
|
+
/namespace\s+:api/,
|
|
248
|
+
/scope\s+["']\//,
|
|
249
|
+
];
|
|
250
|
+
const matches = [];
|
|
251
|
+
for (const dir of searchDirs) {
|
|
252
|
+
const files = globRecursive(dir, [".py", ".ts", ".js", ".go", ".rb", ".php", ".rs"]);
|
|
253
|
+
for (const f of files) {
|
|
254
|
+
if (!entryCandidates.includes(path.basename(f)))
|
|
255
|
+
continue;
|
|
256
|
+
if (/test/i.test(f))
|
|
257
|
+
continue;
|
|
258
|
+
let fc;
|
|
259
|
+
try {
|
|
260
|
+
fc = fs.readFileSync(f, "utf-8");
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const hasMounting = mountPatterns.some((p) => p.test(fc));
|
|
266
|
+
if (!hasMounting)
|
|
267
|
+
continue;
|
|
268
|
+
const relPath = f.startsWith(repositoryPath)
|
|
269
|
+
? f.slice(repositoryPath.length + 1)
|
|
270
|
+
: f;
|
|
271
|
+
const relevantLines = [];
|
|
272
|
+
for (const line of fc.split("\n")) {
|
|
273
|
+
const trimmed = line.trim();
|
|
274
|
+
if (/include_router|register_blueprint/i.test(trimmed) ||
|
|
275
|
+
/\.use\s*\(\s*["']\//.test(trimmed) ||
|
|
276
|
+
/path\s*\(.+include\s*\(/.test(trimmed) ||
|
|
277
|
+
/^from\s+.+import\s+.*router/i.test(trimmed) ||
|
|
278
|
+
/^import\s+.+router/i.test(trimmed) ||
|
|
279
|
+
/^const\s+\w+Router\s*=\s*require/i.test(trimmed) ||
|
|
280
|
+
/^import\s+\w+Router\s+from/i.test(trimmed) ||
|
|
281
|
+
/\.Group\s*\(/.test(trimmed) ||
|
|
282
|
+
/Route::prefix/i.test(trimmed) ||
|
|
283
|
+
/namespace\s+:/.test(trimmed) ||
|
|
284
|
+
/scope\s+["']\//.test(trimmed)) {
|
|
285
|
+
relevantLines.push(trimmed);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (relevantLines.length > 0) {
|
|
289
|
+
matches.push(`${relPath}:\n${relevantLines.map((l) => " " + l).join("\n")}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return matches.join("\n\n");
|
|
294
|
+
}
|
|
295
|
+
function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath) {
|
|
296
|
+
const relative = sourceFile.startsWith(repositoryPath)
|
|
297
|
+
? sourceFile.slice(repositoryPath.length + 1) : sourceFile;
|
|
298
|
+
const existing = endpointMap.get(apiPath);
|
|
299
|
+
if (existing)
|
|
300
|
+
existing.methods.add(method);
|
|
301
|
+
else
|
|
302
|
+
endpointMap.set(apiPath, { methods: new Set([method]), sourceFile: relative });
|
|
303
|
+
}
|
|
304
|
+
function scanNextjsFile(file, repositoryPath, endpointMap) {
|
|
305
|
+
const relative = file.startsWith(repositoryPath)
|
|
306
|
+
? file.slice(repositoryPath.length + 1) : file;
|
|
307
|
+
const apiPath = nextjsFileToApiPath(relative);
|
|
308
|
+
if (!apiPath)
|
|
309
|
+
return false;
|
|
310
|
+
let content;
|
|
311
|
+
try {
|
|
312
|
+
content = fs.readFileSync(file, "utf-8");
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
addEndpointToMap(endpointMap, apiPath, "MULTI", file, repositoryPath);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const detectedMethods = new Set();
|
|
319
|
+
for (const line of content.split("\n")) {
|
|
320
|
+
const mc = line.match(/req\.method\s*===?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
|
|
321
|
+
if (mc)
|
|
322
|
+
detectedMethods.add(mc[1].toUpperCase());
|
|
323
|
+
const em = line.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
|
|
324
|
+
if (em)
|
|
325
|
+
detectedMethods.add(em[1].toUpperCase());
|
|
326
|
+
}
|
|
327
|
+
if (detectedMethods.size > 0) {
|
|
328
|
+
for (const m of detectedMethods)
|
|
329
|
+
addEndpointToMap(endpointMap, apiPath, m, file, repositoryPath);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
addEndpointToMap(endpointMap, apiPath, "MULTI", file, repositoryPath);
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
export function scanAllRepoEndpoints(repositoryPath) {
|
|
337
|
+
const endpointMap = new Map();
|
|
338
|
+
const appPrefix = detectApiPrefix(repositoryPath);
|
|
339
|
+
const sourceFiles = globRecursive(repositoryPath, [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".kt", ".go", ".rb", ".php", ".rs", ".cs"]);
|
|
340
|
+
for (const file of sourceFiles) {
|
|
341
|
+
if (scanNextjsFile(file, repositoryPath, endpointMap))
|
|
342
|
+
continue;
|
|
343
|
+
const relative = file.startsWith(repositoryPath)
|
|
344
|
+
? file.slice(repositoryPath.length + 1) : file;
|
|
345
|
+
if (!/route|controller|endpoint|handler|view|urls|api|router/i.test(relative))
|
|
346
|
+
continue;
|
|
347
|
+
let content;
|
|
348
|
+
try {
|
|
349
|
+
content = fs.readFileSync(file, "utf-8");
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (file.endsWith(".py")) {
|
|
355
|
+
const pyEndpoints = extractEndpointsFromPythonFile(content, relative);
|
|
356
|
+
for (const ep of pyEndpoints) {
|
|
357
|
+
const fullPath = appPrefix && !ep.path.startsWith(appPrefix) ? appPrefix + ep.path : ep.path;
|
|
358
|
+
addEndpointToMap(endpointMap, fullPath, ep.method, file, repositoryPath);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
for (const line of content.split("\n")) {
|
|
363
|
+
const ep = parseRouteLine(line, relative);
|
|
364
|
+
if (ep)
|
|
365
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return Array.from(endpointMap.entries()).map(([apiPath, data]) => ({
|
|
370
|
+
path: apiPath,
|
|
371
|
+
methods: Array.from(data.methods),
|
|
372
|
+
sourceFile: data.sourceFile,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
376
|
+
const appPrefix = detectApiPrefix(repositoryPath);
|
|
377
|
+
const relatedDirs = new Set();
|
|
378
|
+
for (const f of changedFiles) {
|
|
379
|
+
const absDir = path.dirname(path.join(repositoryPath, f));
|
|
380
|
+
relatedDirs.add(absDir);
|
|
381
|
+
const parentDir = path.dirname(absDir);
|
|
382
|
+
if (parentDir !== repositoryPath)
|
|
383
|
+
relatedDirs.add(parentDir);
|
|
384
|
+
}
|
|
385
|
+
const endpointMap = new Map();
|
|
386
|
+
for (const dir of relatedDirs) {
|
|
387
|
+
if (!fs.existsSync(dir))
|
|
388
|
+
continue;
|
|
389
|
+
const files = globRecursive(dir, [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".kt", ".go", ".rb", ".php", ".rs", ".cs"]);
|
|
390
|
+
for (const file of files) {
|
|
391
|
+
if (scanNextjsFile(file, repositoryPath, endpointMap))
|
|
392
|
+
continue;
|
|
393
|
+
const relative = file.startsWith(repositoryPath)
|
|
394
|
+
? file.slice(repositoryPath.length + 1) : file;
|
|
395
|
+
if (!/route|controller|endpoint|handler|view|urls|api|router/i.test(relative))
|
|
396
|
+
continue;
|
|
397
|
+
let fileContent;
|
|
398
|
+
try {
|
|
399
|
+
fileContent = fs.readFileSync(file, "utf-8");
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (file.endsWith(".py")) {
|
|
405
|
+
const pyEndpoints = extractEndpointsFromPythonFile(fileContent, relative);
|
|
406
|
+
for (const ep of pyEndpoints) {
|
|
407
|
+
const fullPath = appPrefix && !ep.path.startsWith(appPrefix) ? appPrefix + ep.path : ep.path;
|
|
408
|
+
addEndpointToMap(endpointMap, fullPath, ep.method, file, repositoryPath);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
for (const line of fileContent.split("\n")) {
|
|
413
|
+
const ep = parseRouteLine(line, relative);
|
|
414
|
+
if (ep)
|
|
415
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return Array.from(endpointMap.entries()).map(([apiPath, data]) => ({
|
|
421
|
+
path: apiPath,
|
|
422
|
+
methods: Array.from(data.methods),
|
|
423
|
+
sourceFile: data.sourceFile,
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
@@ -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
|
+
}
|