@skyramp/mcp 0.2.0-rc.1 → 0.2.0-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.
- package/build/index.js +4 -2
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +215 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +3 -1
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +222 -1
- package/build/prompts/testbot/testbot-prompts.js +18 -62
- package/build/prompts/testbot/testbot-prompts.test.js +65 -31
- package/build/services/ScenarioGenerationService.js +11 -1
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +171 -63
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +168 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +54 -5
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +4 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +6 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +21 -4
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +3 -0
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { scanAllRepoEndpoints, scanRelatedEndpoints } from "./repoScanner.js";
|
|
5
|
+
import { classifyEndpointsByChangedFiles } from "./routeParsers.js";
|
|
6
|
+
describe("scanAllRepoEndpoints", () => {
|
|
7
|
+
const tempRepos = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const repo of tempRepos.splice(0)) {
|
|
10
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
function makeTempRepo() {
|
|
14
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "repo-scanner-"));
|
|
15
|
+
tempRepos.push(repo);
|
|
16
|
+
return repo;
|
|
17
|
+
}
|
|
18
|
+
it("preserves same router-relative paths from different source files", () => {
|
|
19
|
+
const repo = makeTempRepo();
|
|
20
|
+
const apiDir = path.join(repo, "src/prefect/server/api");
|
|
21
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(apiDir, "flows.py"), `
|
|
23
|
+
@router.delete(
|
|
24
|
+
"/{id:uuid}",
|
|
25
|
+
status_code=204,
|
|
26
|
+
)
|
|
27
|
+
async def delete_flow(): pass
|
|
28
|
+
|
|
29
|
+
@router.post("/bulk_delete")
|
|
30
|
+
async def bulk_delete_flows(): pass
|
|
31
|
+
`);
|
|
32
|
+
fs.writeFileSync(path.join(apiDir, "deployments.py"), `
|
|
33
|
+
@router.delete(
|
|
34
|
+
"/{id:uuid}",
|
|
35
|
+
status_code=204,
|
|
36
|
+
)
|
|
37
|
+
async def delete_deployment(): pass
|
|
38
|
+
|
|
39
|
+
@router.post("/bulk_delete")
|
|
40
|
+
async def bulk_delete_deployments(): pass
|
|
41
|
+
`);
|
|
42
|
+
const endpoints = scanAllRepoEndpoints(repo);
|
|
43
|
+
const relatedEndpoints = scanRelatedEndpoints(repo, ["src/prefect/server/api/flows.py"]);
|
|
44
|
+
expect(endpoints).toEqual(expect.arrayContaining([
|
|
45
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
46
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
47
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
48
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
49
|
+
]));
|
|
50
|
+
expect(relatedEndpoints).toEqual(expect.arrayContaining([
|
|
51
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
52
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
53
|
+
]));
|
|
54
|
+
const classified = classifyEndpointsByChangedFiles({
|
|
55
|
+
currentBranch: "feature/admin-key",
|
|
56
|
+
baseBranch: "origin/main",
|
|
57
|
+
changedFiles: ["src/prefect/server/api/flows.py"],
|
|
58
|
+
diffContent: "",
|
|
59
|
+
diffStat: "",
|
|
60
|
+
newFiles: [],
|
|
61
|
+
deletedFiles: [],
|
|
62
|
+
}, endpoints);
|
|
63
|
+
expect(classified.changedEndpoints).toEqual(expect.arrayContaining([
|
|
64
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
65
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
66
|
+
]));
|
|
67
|
+
expect(classified.changedEndpoints).not.toEqual(expect.arrayContaining([
|
|
68
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
69
|
+
]));
|
|
70
|
+
});
|
|
71
|
+
it("does not suppress removed router-relative endpoints with same-path siblings", () => {
|
|
72
|
+
const classified = classifyEndpointsByChangedFiles({
|
|
73
|
+
currentBranch: "feature/remove-flows",
|
|
74
|
+
baseBranch: "origin/main",
|
|
75
|
+
changedFiles: ["src/prefect/server/api/flows.py"],
|
|
76
|
+
diffContent: "",
|
|
77
|
+
diffStat: "",
|
|
78
|
+
newFiles: [],
|
|
79
|
+
deletedFiles: ["src/prefect/server/api/flows.py"],
|
|
80
|
+
}, [
|
|
81
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
82
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
83
|
+
], [
|
|
84
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
85
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
86
|
+
]);
|
|
87
|
+
expect(classified.removedEndpoints).toEqual(expect.arrayContaining([
|
|
88
|
+
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
89
|
+
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
90
|
+
]));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -32,6 +32,10 @@ export const UI_FILE_EXTENSIONS = ['tsx', 'jsx', 'vue', 'svelte', 'html', 'xml']
|
|
|
32
32
|
// (which means `--encoding`, not extended regex). Each entry is `*.ext`;
|
|
33
33
|
// pass them as `-- '*.tsx' '*.jsx' ...` to git diff.
|
|
34
34
|
export const UI_FILE_GIT_PATHSPEC = UI_FILE_EXTENSIONS.map(ext => `'*.${ext}'`).join(' ');
|
|
35
|
+
// Pipe-joined form of the UI extensions for use inside an extended regex
|
|
36
|
+
// (e.g. `grep -E '\\.(tsx|jsx|vue|svelte|html|xml) '`). Mirrors
|
|
37
|
+
// UI_FILE_GIT_PATHSPEC but for grep over a pre-computed diff file.
|
|
38
|
+
export const UI_FILE_EXTENSIONS_PIPED = UI_FILE_EXTENSIONS.join('|');
|
|
35
39
|
export function parseRouteLine(line, sourceFile) {
|
|
36
40
|
const stripped = line.replace(/^[+-]\s*/, "").trim();
|
|
37
41
|
const isComponent = UI_COMPONENT_EXT.test(sourceFile);
|
|
@@ -186,6 +190,8 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
186
190
|
// FastAPI APIRouter / Flask Blueprint: variable name → prefix
|
|
187
191
|
const routerPrefixes = new Map();
|
|
188
192
|
let pendingRouterVar = ""; // variable name being accumulated in a multi-line declaration
|
|
193
|
+
let pendingPythonDecorator = null;
|
|
194
|
+
let pendingExpressMethod = ""; // HTTP method from a multi-line Express route (e.g. "POST" from `router.post(\n`)
|
|
189
195
|
for (const line of content.split("\n")) {
|
|
190
196
|
const trimmed = line.trim();
|
|
191
197
|
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
@@ -247,6 +253,73 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
247
253
|
continue;
|
|
248
254
|
}
|
|
249
255
|
}
|
|
256
|
+
if (pendingPythonDecorator) {
|
|
257
|
+
// FastAPI decorators commonly split the route path onto the next line:
|
|
258
|
+
// @router.delete(
|
|
259
|
+
// "/{id}",
|
|
260
|
+
// ...
|
|
261
|
+
// )
|
|
262
|
+
const pathMatch = trimmed.match(/^(?:path\s*=\s*)?["']([^"'?#]*)/) ||
|
|
263
|
+
trimmed.match(/^value\s*=\s*["']([^"'?#]*)/);
|
|
264
|
+
if (pathMatch) {
|
|
265
|
+
const routerPrefix = routerPrefixes.get(pendingPythonDecorator.routerVar);
|
|
266
|
+
const prefix = routerPrefix?.replace(/\/$/, "");
|
|
267
|
+
const suffix = pathMatch[1].replace(/^\//, "");
|
|
268
|
+
const path = prefix
|
|
269
|
+
? (suffix ? `${prefix}/${suffix}` : prefix)
|
|
270
|
+
: pathMatch[1];
|
|
271
|
+
results.push({
|
|
272
|
+
method: pendingPythonDecorator.method,
|
|
273
|
+
path,
|
|
274
|
+
sourceFile,
|
|
275
|
+
});
|
|
276
|
+
pendingPythonDecorator = null;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (/^[\]\)\}]+[;,)]*\s*(?:#.*)?$/.test(trimmed) ||
|
|
280
|
+
trimmed.startsWith("@") ||
|
|
281
|
+
/^async\s+def\b|^def\b/.test(trimmed)) {
|
|
282
|
+
pendingPythonDecorator = null;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// ── Multi-line Express route state machine ────────────────────────────────
|
|
289
|
+
// Handles patterns like:
|
|
290
|
+
// router.post(
|
|
291
|
+
// '/:collection',
|
|
292
|
+
// middleware,
|
|
293
|
+
// handler
|
|
294
|
+
// );
|
|
295
|
+
if (pendingExpressMethod) {
|
|
296
|
+
// Look for the first path-like string literal on this line
|
|
297
|
+
const pathMatch = trimmed.match(/["'](\/[^"'?#]*|:[^"'?#]+)["']/);
|
|
298
|
+
if (pathMatch) {
|
|
299
|
+
const isComponent = UI_COMPONENT_EXT.test(sourceFile);
|
|
300
|
+
if (!isComponent) {
|
|
301
|
+
results.push({
|
|
302
|
+
method: pendingExpressMethod,
|
|
303
|
+
path: pathMatch[1],
|
|
304
|
+
sourceFile,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
pendingExpressMethod = "";
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// If we hit a closing delimiter-only line or another route call, abandon the pending state
|
|
311
|
+
if (/^[\]\)\}]+[;,)]*\s*(?:\/\/.*)?$/.test(trimmed)) {
|
|
312
|
+
pendingExpressMethod = "";
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (/(?:router|app)\.(get|post|put|patch|delete)\s*\(/i.test(trimmed)) {
|
|
316
|
+
// New route call — reset and fall through to parse this line normally
|
|
317
|
+
pendingExpressMethod = "";
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
continue; // Skip middleware/handler lines
|
|
321
|
+
}
|
|
322
|
+
}
|
|
250
323
|
// ── Non-annotation line → clear any dangling pendingPrefix ───────────────
|
|
251
324
|
// Prevents method-level @RequestMapping (no method=) from being misidentified
|
|
252
325
|
if (pendingPrefix && !trimmed.startsWith("@") && !trimmed.startsWith("[")) {
|
|
@@ -260,8 +333,24 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
260
333
|
if (nestjsNoPath)
|
|
261
334
|
ep = { method: nestjsNoPath[1].toUpperCase(), path: "", sourceFile };
|
|
262
335
|
}
|
|
263
|
-
if (!ep)
|
|
336
|
+
if (!ep) {
|
|
337
|
+
// Detect multi-line FastAPI/Flask-style decorators with the path on a later line.
|
|
338
|
+
const pythonDecoratorMultiLine = trimmed.match(/@(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*$/i);
|
|
339
|
+
if (pythonDecoratorMultiLine) {
|
|
340
|
+
pendingPythonDecorator = {
|
|
341
|
+
routerVar: pythonDecoratorMultiLine[1],
|
|
342
|
+
method: pythonDecoratorMultiLine[2].toUpperCase(),
|
|
343
|
+
};
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Detect multi-line Express route: method call with no path string on same line
|
|
347
|
+
// e.g. "router.post(" or "app.get(\n"
|
|
348
|
+
const expressMultiLine = trimmed.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*$/i);
|
|
349
|
+
if (expressMultiLine && !UI_COMPONENT_EXT.test(sourceFile)) {
|
|
350
|
+
pendingExpressMethod = expressMultiLine[1].toUpperCase();
|
|
351
|
+
}
|
|
264
352
|
continue;
|
|
353
|
+
}
|
|
265
354
|
// ── Apply prefix ──────────────────────────────────────────────────────────
|
|
266
355
|
if (classPrefix) {
|
|
267
356
|
// Class-based prefix (Spring, NestJS, ASP.NET)
|
|
@@ -292,11 +381,30 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
292
381
|
return results;
|
|
293
382
|
}
|
|
294
383
|
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
295
|
-
/**
|
|
384
|
+
/**
|
|
385
|
+
* Extract the primary resource key from an endpoint path for use as a dedup key.
|
|
386
|
+
*
|
|
387
|
+
* For top-level resources (e.g. "/api/v1/orders/{id}") returns the resource name ("orders").
|
|
388
|
+
* For sub-resources (e.g. "/orders/{id}/items") returns "orders_items" — including the
|
|
389
|
+
* parent prevents "/orders/{id}/items" and "/products/{id}/items" from colliding on "items".
|
|
390
|
+
*/
|
|
296
391
|
export function extractResourceFromPath(endpointPath) {
|
|
297
392
|
const segments = endpointPath.split("/").filter(Boolean);
|
|
298
393
|
const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
|
|
299
|
-
|
|
394
|
+
if (meaningful.length === 0)
|
|
395
|
+
return "unknown";
|
|
396
|
+
// Sub-resource pattern: /…/<parent>/{param}/<child>
|
|
397
|
+
// Detected when the last raw segment is non-param and the second-to-last is a param.
|
|
398
|
+
const last = segments[segments.length - 1];
|
|
399
|
+
const secondLast = segments.length >= 2 ? segments[segments.length - 2] : "";
|
|
400
|
+
if (!last.startsWith("{") && secondLast.startsWith("{") && meaningful.length >= 2) {
|
|
401
|
+
return `${meaningful[meaningful.length - 2]}_${meaningful[meaningful.length - 1]}`;
|
|
402
|
+
}
|
|
403
|
+
return meaningful[meaningful.length - 1];
|
|
404
|
+
}
|
|
405
|
+
const GENERIC_ROUTE_BASENAMES = new Set(["api", "app", "handler", "handlers", "index", "route", "routes", "router", "server"]);
|
|
406
|
+
function normalizeSourcePath(filePath) {
|
|
407
|
+
return filePath.replace(/\\/g, "/");
|
|
300
408
|
}
|
|
301
409
|
/**
|
|
302
410
|
* Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
|
|
@@ -309,30 +417,33 @@ export function extractResourceFromPath(endpointPath) {
|
|
|
309
417
|
* (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
|
|
310
418
|
*/
|
|
311
419
|
export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
|
|
312
|
-
const newFileSet = new Set(diffData.newFiles);
|
|
313
|
-
const deletedFileSet = new Set(diffData.deletedFiles);
|
|
314
|
-
// Build sourceFile
|
|
420
|
+
const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
|
|
421
|
+
const deletedFileSet = new Set(diffData.deletedFiles.map(normalizeSourcePath));
|
|
422
|
+
// Build sourceFile -> ScannedEndpoint[] map from the post-change catalog.
|
|
423
|
+
// Git diffs use POSIX separators; scanner output can reflect OS-native paths.
|
|
315
424
|
const bySourceFile = new Map();
|
|
316
425
|
for (const ep of scannedEndpoints) {
|
|
317
|
-
const
|
|
426
|
+
const sourceKey = normalizeSourcePath(ep.sourceFile);
|
|
427
|
+
const existing = bySourceFile.get(sourceKey);
|
|
318
428
|
if (existing)
|
|
319
429
|
existing.push(ep);
|
|
320
430
|
else
|
|
321
|
-
bySourceFile.set(
|
|
431
|
+
bySourceFile.set(sourceKey, [ep]);
|
|
322
432
|
}
|
|
323
433
|
const changedEndpoints = [];
|
|
324
434
|
const newEndpoints = [];
|
|
325
435
|
const unmatchedFiles = [];
|
|
326
436
|
for (const file of diffData.changedFiles) {
|
|
437
|
+
const fileKey = normalizeSourcePath(file);
|
|
327
438
|
// Deleted files won't appear in scanned catalog — handled separately below
|
|
328
|
-
if (deletedFileSet.has(
|
|
439
|
+
if (deletedFileSet.has(fileKey))
|
|
329
440
|
continue;
|
|
330
|
-
const eps = bySourceFile.get(
|
|
441
|
+
const eps = bySourceFile.get(fileKey);
|
|
331
442
|
if (!eps || eps.length === 0) {
|
|
332
443
|
unmatchedFiles.push(file);
|
|
333
444
|
continue;
|
|
334
445
|
}
|
|
335
|
-
if (newFileSet.has(
|
|
446
|
+
if (newFileSet.has(fileKey)) {
|
|
336
447
|
newEndpoints.push(...eps);
|
|
337
448
|
}
|
|
338
449
|
else {
|
|
@@ -340,23 +451,55 @@ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, dele
|
|
|
340
451
|
}
|
|
341
452
|
}
|
|
342
453
|
// Removed endpoints: from deleted files, recovered from the base branch.
|
|
343
|
-
// Filter out
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
454
|
+
// Filter out endpoints that still exist in the current catalog as moved/refactored.
|
|
455
|
+
// Same router-relative paths can exist in sibling files, so only treat them as moved
|
|
456
|
+
// when the source resource also matches.
|
|
457
|
+
function endpointSourceResource(ep) {
|
|
458
|
+
const sourceSegments = ep.sourceFile.split(/[\\/]/).filter(Boolean);
|
|
459
|
+
const base = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "") ?? "";
|
|
460
|
+
const normalizedBase = base.replace(/^(?:old|new)[-_]/i, "").toLowerCase();
|
|
461
|
+
const isDynamicBase = /^\[.*\]$/.test(normalizedBase) || /^\{.*\}$/.test(normalizedBase);
|
|
462
|
+
if (normalizedBase && !isDynamicBase && !GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
|
|
463
|
+
return normalizedBase;
|
|
464
|
+
}
|
|
465
|
+
if (normalizedBase && GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
|
|
466
|
+
const contextSegment = [...sourceSegments]
|
|
467
|
+
.slice(0, -1)
|
|
468
|
+
.reverse()
|
|
469
|
+
.map((segment) => segment.replace(/\.[^.]+$/, "").replace(/^(?:old|new)[-_]/i, "").toLowerCase())
|
|
470
|
+
.find((segment) => segment && !GENERIC_ROUTE_BASENAMES.has(segment) && !/^\[.*\]$/.test(segment) && !/^\{.*\}$/.test(segment));
|
|
471
|
+
if (contextSegment)
|
|
472
|
+
return `${contextSegment}/${normalizedBase}`;
|
|
473
|
+
}
|
|
474
|
+
const staticSegments = ep.path.split("/").filter(Boolean).filter((segment) => !/^[:{[]/.test(segment));
|
|
475
|
+
return staticSegments[staticSegments.length - 1]?.toLowerCase() ?? normalizedBase;
|
|
476
|
+
}
|
|
477
|
+
const currentMethodKeys = new Set();
|
|
478
|
+
const currentPathResourceKeys = new Set();
|
|
479
|
+
const currentMultiKeys = new Set();
|
|
480
|
+
const movedKey = (path, resource, method) => method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
|
|
481
|
+
for (const current of scannedEndpoints) {
|
|
482
|
+
const currentResource = endpointSourceResource(current);
|
|
483
|
+
const pathResourceKey = movedKey(current.path, currentResource);
|
|
484
|
+
currentPathResourceKeys.add(pathResourceKey);
|
|
485
|
+
for (const method of current.methods) {
|
|
486
|
+
if (method === "MULTI")
|
|
487
|
+
currentMultiKeys.add(pathResourceKey);
|
|
488
|
+
currentMethodKeys.add(movedKey(current.path, currentResource, method));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function isMovedEndpoint(deleted, method) {
|
|
492
|
+
const deletedResource = endpointSourceResource(deleted);
|
|
493
|
+
const pathResourceKey = movedKey(deleted.path, deletedResource);
|
|
494
|
+
if (method === "MULTI")
|
|
495
|
+
return currentPathResourceKeys.has(pathResourceKey);
|
|
496
|
+
return currentMultiKeys.has(pathResourceKey) ||
|
|
497
|
+
currentMethodKeys.has(movedKey(deleted.path, deletedResource, method));
|
|
498
|
+
}
|
|
350
499
|
const removedEndpoints = (deletedFileEndpoints ?? [])
|
|
351
500
|
.map((ep) => ({
|
|
352
501
|
...ep,
|
|
353
|
-
methods: ep.methods.filter((m) =>
|
|
354
|
-
// A deleted MULTI means the file was a catch-all (e.g. Next.js default export).
|
|
355
|
-
// If the path exists in the current catalog with any method, it was moved — not removed.
|
|
356
|
-
if (m === "MULTI")
|
|
357
|
-
return !currentPaths.has(ep.path);
|
|
358
|
-
return !currentEndpointKeys.has(`${m} ${ep.path}`) && !multiPaths.has(ep.path);
|
|
359
|
-
}),
|
|
502
|
+
methods: ep.methods.filter((m) => !isMovedEndpoint(ep, m)),
|
|
360
503
|
}))
|
|
361
504
|
.filter((ep) => ep.methods.length > 0);
|
|
362
505
|
// Affected services: same heuristic as before
|
|
@@ -257,6 +257,120 @@ async def get_product(): pass
|
|
|
257
257
|
const eps = parseFileEndpoints(content, "routers/product.py");
|
|
258
258
|
expect(eps).toContainEqual({ method: "GET", path: "/products/{product_id}", sourceFile: "routers/product.py" });
|
|
259
259
|
});
|
|
260
|
+
it("FastAPI multi-line method decorators capture path arguments", () => {
|
|
261
|
+
const content = `
|
|
262
|
+
router = APIRouter(
|
|
263
|
+
prefix="/flows",
|
|
264
|
+
tags=["flows"],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
@router.delete(
|
|
268
|
+
"/{id:uuid}",
|
|
269
|
+
status_code=204,
|
|
270
|
+
)
|
|
271
|
+
async def delete_flow(): pass
|
|
272
|
+
|
|
273
|
+
@router.post(
|
|
274
|
+
"/bulk_delete",
|
|
275
|
+
status_code=204,
|
|
276
|
+
)
|
|
277
|
+
async def bulk_delete_flows(): pass
|
|
278
|
+
`;
|
|
279
|
+
const eps = parseFileEndpoints(content, "src/prefect/server/api/flows.py");
|
|
280
|
+
expect(eps).toContainEqual({ method: "DELETE", path: "/flows/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" });
|
|
281
|
+
expect(eps).toContainEqual({ method: "POST", path: "/flows/bulk_delete", sourceFile: "src/prefect/server/api/flows.py" });
|
|
282
|
+
});
|
|
283
|
+
// ── Multi-line Express route patterns ──────────────────────────────────────
|
|
284
|
+
it("Express multi-line: router.post( with path on next line", () => {
|
|
285
|
+
const content = `
|
|
286
|
+
router.post(
|
|
287
|
+
\t'/:collection',
|
|
288
|
+
\tasyncHandler(async (req, res, next) => {})
|
|
289
|
+
);
|
|
290
|
+
`;
|
|
291
|
+
const eps = parseFileEndpoints(content, "controllers/collections.ts");
|
|
292
|
+
expect(eps).toContainEqual({ method: "POST", path: "/:collection", sourceFile: "controllers/collections.ts" });
|
|
293
|
+
});
|
|
294
|
+
it("Express multi-line: app.get( with path on next line", () => {
|
|
295
|
+
const content = `
|
|
296
|
+
app.get(
|
|
297
|
+
\t'/health',
|
|
298
|
+
\t(req, res) => res.json({ status: 'ok' })
|
|
299
|
+
);
|
|
300
|
+
`;
|
|
301
|
+
const eps = parseFileEndpoints(content, "server.ts");
|
|
302
|
+
expect(eps).toContainEqual({ method: "GET", path: "/health", sourceFile: "server.ts" });
|
|
303
|
+
});
|
|
304
|
+
it("Express multi-line: does not fire for single-line routes", () => {
|
|
305
|
+
const content = `router.get('/api/products', handler)`;
|
|
306
|
+
const eps = parseFileEndpoints(content, "routes.ts");
|
|
307
|
+
expect(eps).toHaveLength(1);
|
|
308
|
+
expect(eps[0]).toEqual({ method: "GET", path: "/api/products", sourceFile: "routes.ts" });
|
|
309
|
+
});
|
|
310
|
+
it("Express multi-line: handles multiple consecutive multi-line routes", () => {
|
|
311
|
+
const content = `
|
|
312
|
+
router.post(
|
|
313
|
+
\t'/',
|
|
314
|
+
\tasyncHandler(async (req, res) => {})
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
router.get(
|
|
318
|
+
\t'/:pk',
|
|
319
|
+
\tasyncHandler(async (req, res) => {})
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
router.delete(
|
|
323
|
+
\t'/:pk',
|
|
324
|
+
\tasyncHandler(async (req, res) => {})
|
|
325
|
+
);
|
|
326
|
+
`;
|
|
327
|
+
const eps = parseFileEndpoints(content, "controllers/items.ts");
|
|
328
|
+
expect(eps).toHaveLength(3);
|
|
329
|
+
expect(eps).toContainEqual({ method: "POST", path: "/", sourceFile: "controllers/items.ts" });
|
|
330
|
+
expect(eps).toContainEqual({ method: "GET", path: "/:pk", sourceFile: "controllers/items.ts" });
|
|
331
|
+
expect(eps).toContainEqual({ method: "DELETE", path: "/:pk", sourceFile: "controllers/items.ts" });
|
|
332
|
+
});
|
|
333
|
+
it("Express multi-line: ignores component files (.tsx)", () => {
|
|
334
|
+
const content = `
|
|
335
|
+
router.post(
|
|
336
|
+
\t'/:collection',
|
|
337
|
+
\thandler
|
|
338
|
+
);
|
|
339
|
+
`;
|
|
340
|
+
const eps = parseFileEndpoints(content, "components/Form.tsx");
|
|
341
|
+
expect(eps).toHaveLength(0);
|
|
342
|
+
});
|
|
343
|
+
it("Express multi-line: abandons pending state on closing paren without path", () => {
|
|
344
|
+
const content = `
|
|
345
|
+
router.post(
|
|
346
|
+
);
|
|
347
|
+
router.get('/fallback', handler);
|
|
348
|
+
`;
|
|
349
|
+
const eps = parseFileEndpoints(content, "routes.ts");
|
|
350
|
+
expect(eps).toHaveLength(1);
|
|
351
|
+
expect(eps[0]).toEqual({ method: "GET", path: "/fallback", sourceFile: "routes.ts" });
|
|
352
|
+
});
|
|
353
|
+
it("Express multi-line: mixes single-line and multi-line routes", () => {
|
|
354
|
+
const content = `
|
|
355
|
+
router.get('/', validateBatch('read'), readHandler, respond);
|
|
356
|
+
|
|
357
|
+
router.get(
|
|
358
|
+
\t'/:pk',
|
|
359
|
+
\tasyncHandler(async (req, res, next) => {})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
router.patch(
|
|
363
|
+
\t'/:pk',
|
|
364
|
+
\tvalidateBatch('update'),
|
|
365
|
+
\tasyncHandler(async (req, res, next) => {})
|
|
366
|
+
);
|
|
367
|
+
`;
|
|
368
|
+
const eps = parseFileEndpoints(content, "controllers/access.ts");
|
|
369
|
+
expect(eps).toHaveLength(3);
|
|
370
|
+
expect(eps).toContainEqual({ method: "GET", path: "/", sourceFile: "controllers/access.ts" });
|
|
371
|
+
expect(eps).toContainEqual({ method: "GET", path: "/:pk", sourceFile: "controllers/access.ts" });
|
|
372
|
+
expect(eps).toContainEqual({ method: "PATCH", path: "/:pk", sourceFile: "controllers/access.ts" });
|
|
373
|
+
});
|
|
260
374
|
});
|
|
261
375
|
describe("extractResourceFromPath", () => {
|
|
262
376
|
it("extracts resource from simple path", () => {
|
|
@@ -271,7 +385,7 @@ describe("extractResourceFromPath", () => {
|
|
|
271
385
|
expect(extractResourceFromPath("/api/v1/orders/{order_id}")).toBe("orders");
|
|
272
386
|
});
|
|
273
387
|
it("handles nested resources", () => {
|
|
274
|
-
expect(extractResourceFromPath("/api/v1/orders/{order_id}/items")).toBe("
|
|
388
|
+
expect(extractResourceFromPath("/api/v1/orders/{order_id}/items")).toBe("orders_items");
|
|
275
389
|
});
|
|
276
390
|
it("handles public prefix", () => {
|
|
277
391
|
expect(extractResourceFromPath("/public/products")).toBe("products");
|
|
@@ -456,6 +570,71 @@ describe("classifyEndpointsByChangedFiles", () => {
|
|
|
456
570
|
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
|
|
457
571
|
expect(result.removedEndpoints).toHaveLength(0);
|
|
458
572
|
});
|
|
573
|
+
it("matches POSIX diff paths against Windows-style scanned source files", () => {
|
|
574
|
+
const windowsScanned = [
|
|
575
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET"], sourceFile: "src\\routes\\orders.py" },
|
|
576
|
+
{ path: "/api/v1/products/{product_id}", methods: ["POST"], sourceFile: "src\\routes\\products.py" },
|
|
577
|
+
];
|
|
578
|
+
const diff = makeDiffDataForClassify({
|
|
579
|
+
changedFiles: ["src/routes/orders.py", "src/routes/products.py", "src/routes/legacy.py"],
|
|
580
|
+
newFiles: ["src/routes/products.py"],
|
|
581
|
+
deletedFiles: ["src/routes/legacy.py"],
|
|
582
|
+
});
|
|
583
|
+
const result = classifyEndpointsByChangedFiles(diff, windowsScanned, [
|
|
584
|
+
{ path: "/api/v1/legacy", methods: ["DELETE"], sourceFile: "src\\routes\\legacy.py" },
|
|
585
|
+
]);
|
|
586
|
+
expect(result.changedEndpoints).toEqual([windowsScanned[0]]);
|
|
587
|
+
expect(result.newEndpoints).toEqual([windowsScanned[1]]);
|
|
588
|
+
expect(result.removedEndpoints).toEqual([
|
|
589
|
+
{ path: "/api/v1/legacy", methods: ["DELETE"], sourceFile: "src\\routes\\legacy.py" },
|
|
590
|
+
]);
|
|
591
|
+
expect(result.unmatchedFiles).toEqual([]);
|
|
592
|
+
expect(result.changedFiles).toEqual(diff.changedFiles);
|
|
593
|
+
});
|
|
594
|
+
it("uses Windows path separators when comparing moved endpoint resources", () => {
|
|
595
|
+
const current = [
|
|
596
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET"], sourceFile: "src/routes/orders.py" },
|
|
597
|
+
];
|
|
598
|
+
const deleted = [
|
|
599
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET", "DELETE"], sourceFile: "src\\routes\\old-orders.py" },
|
|
600
|
+
];
|
|
601
|
+
const diff = makeDiffDataForClassify({
|
|
602
|
+
changedFiles: ["src\\routes\\old-orders.py"],
|
|
603
|
+
deletedFiles: ["src\\routes\\old-orders.py"],
|
|
604
|
+
});
|
|
605
|
+
const result = classifyEndpointsByChangedFiles(diff, current, deleted);
|
|
606
|
+
expect(result.removedEndpoints).toHaveLength(1);
|
|
607
|
+
expect(result.removedEndpoints[0].methods).toEqual(["DELETE"]);
|
|
608
|
+
});
|
|
609
|
+
it("treats generic same-name route files in the same resource folder as moved", () => {
|
|
610
|
+
const current = [
|
|
611
|
+
{ path: "/{id}", methods: ["GET", "DELETE"], sourceFile: "src/api/flows/server.py" },
|
|
612
|
+
];
|
|
613
|
+
const deleted = [
|
|
614
|
+
{ path: "/{id}", methods: ["GET", "DELETE"], sourceFile: "src/api/flows/old-server.py" },
|
|
615
|
+
];
|
|
616
|
+
const diff = makeDiffDataForClassify({
|
|
617
|
+
changedFiles: ["src/api/flows/old-server.py"],
|
|
618
|
+
deletedFiles: ["src/api/flows/old-server.py"],
|
|
619
|
+
});
|
|
620
|
+
const result = classifyEndpointsByChangedFiles(diff, current, deleted);
|
|
621
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
622
|
+
});
|
|
623
|
+
it("does not treat generic same-name route files in different resource folders as moved", () => {
|
|
624
|
+
const current = [
|
|
625
|
+
{ path: "/{id}", methods: ["GET"], sourceFile: "src/api/deployments/server.py" },
|
|
626
|
+
];
|
|
627
|
+
const deleted = [
|
|
628
|
+
{ path: "/{id}", methods: ["GET", "DELETE"], sourceFile: "src/api/flows/server.py" },
|
|
629
|
+
];
|
|
630
|
+
const diff = makeDiffDataForClassify({
|
|
631
|
+
changedFiles: ["src/api/flows/server.py"],
|
|
632
|
+
deletedFiles: ["src/api/flows/server.py"],
|
|
633
|
+
});
|
|
634
|
+
const result = classifyEndpointsByChangedFiles(diff, current, deleted);
|
|
635
|
+
expect(result.removedEndpoints).toHaveLength(1);
|
|
636
|
+
expect(result.removedEndpoints[0].methods).toEqual(["GET", "DELETE"]);
|
|
637
|
+
});
|
|
459
638
|
it("defaults removedEndpoints to empty when deletedFileEndpoints is omitted", () => {
|
|
460
639
|
const diff = makeDiffDataForClassify({
|
|
461
640
|
changedFiles: ["src/routes/legacy.py"],
|