@skyramp/mcp 0.1.8 → 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/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- 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 +246 -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 +4 -3
- 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 +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- 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/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- 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 +180 -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/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -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/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- 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
|
+
});
|
|
@@ -20,6 +20,22 @@ export function nextjsFileToApiPath(filePath) {
|
|
|
20
20
|
}
|
|
21
21
|
// UI component file extensions are unambiguously frontend — never route definitions.
|
|
22
22
|
const UI_COMPONENT_EXT = /\.(jsx|tsx|vue|svelte)$/i;
|
|
23
|
+
// Frontend-file extensions for UI-change detection (broader than UI_COMPONENT_EXT —
|
|
24
|
+
// includes template/markup files that backend-only PRs won't touch). Used by
|
|
25
|
+
// testbot-prompts.ts to gate the UI pre-scan and capture-act-capture sections,
|
|
26
|
+
// and anywhere else that needs to ask "does this PR touch the frontend?".
|
|
27
|
+
// Single source of truth for that question.
|
|
28
|
+
export const UI_FILE_EXTENSIONS = ['tsx', 'jsx', 'vue', 'svelte', 'html', 'xml'];
|
|
29
|
+
// Git pathspec form of the UI-file filter — usable directly with `git diff`'s
|
|
30
|
+
// trailing pathspec args, no external tool needed. Avoids the "agent shell
|
|
31
|
+
// has grep aliased to rg" failure mode where `grep -E` becomes `rg -E`
|
|
32
|
+
// (which means `--encoding`, not extended regex). Each entry is `*.ext`;
|
|
33
|
+
// pass them as `-- '*.tsx' '*.jsx' ...` to git diff.
|
|
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('|');
|
|
23
39
|
export function parseRouteLine(line, sourceFile) {
|
|
24
40
|
const stripped = line.replace(/^[+-]\s*/, "").trim();
|
|
25
41
|
const isComponent = UI_COMPONENT_EXT.test(sourceFile);
|
|
@@ -174,6 +190,8 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
174
190
|
// FastAPI APIRouter / Flask Blueprint: variable name → prefix
|
|
175
191
|
const routerPrefixes = new Map();
|
|
176
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`)
|
|
177
195
|
for (const line of content.split("\n")) {
|
|
178
196
|
const trimmed = line.trim();
|
|
179
197
|
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
@@ -235,6 +253,73 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
235
253
|
continue;
|
|
236
254
|
}
|
|
237
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
|
+
}
|
|
238
323
|
// ── Non-annotation line → clear any dangling pendingPrefix ───────────────
|
|
239
324
|
// Prevents method-level @RequestMapping (no method=) from being misidentified
|
|
240
325
|
if (pendingPrefix && !trimmed.startsWith("@") && !trimmed.startsWith("[")) {
|
|
@@ -248,8 +333,24 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
248
333
|
if (nestjsNoPath)
|
|
249
334
|
ep = { method: nestjsNoPath[1].toUpperCase(), path: "", sourceFile };
|
|
250
335
|
}
|
|
251
|
-
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
|
+
}
|
|
252
352
|
continue;
|
|
353
|
+
}
|
|
253
354
|
// ── Apply prefix ──────────────────────────────────────────────────────────
|
|
254
355
|
if (classPrefix) {
|
|
255
356
|
// Class-based prefix (Spring, NestJS, ASP.NET)
|
|
@@ -280,11 +381,30 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
280
381
|
return results;
|
|
281
382
|
}
|
|
282
383
|
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
283
|
-
/**
|
|
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
|
+
*/
|
|
284
391
|
export function extractResourceFromPath(endpointPath) {
|
|
285
392
|
const segments = endpointPath.split("/").filter(Boolean);
|
|
286
393
|
const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
|
|
287
|
-
|
|
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, "/");
|
|
288
408
|
}
|
|
289
409
|
/**
|
|
290
410
|
* Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
|
|
@@ -297,30 +417,33 @@ export function extractResourceFromPath(endpointPath) {
|
|
|
297
417
|
* (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
|
|
298
418
|
*/
|
|
299
419
|
export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
|
|
300
|
-
const newFileSet = new Set(diffData.newFiles);
|
|
301
|
-
const deletedFileSet = new Set(diffData.deletedFiles);
|
|
302
|
-
// 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.
|
|
303
424
|
const bySourceFile = new Map();
|
|
304
425
|
for (const ep of scannedEndpoints) {
|
|
305
|
-
const
|
|
426
|
+
const sourceKey = normalizeSourcePath(ep.sourceFile);
|
|
427
|
+
const existing = bySourceFile.get(sourceKey);
|
|
306
428
|
if (existing)
|
|
307
429
|
existing.push(ep);
|
|
308
430
|
else
|
|
309
|
-
bySourceFile.set(
|
|
431
|
+
bySourceFile.set(sourceKey, [ep]);
|
|
310
432
|
}
|
|
311
433
|
const changedEndpoints = [];
|
|
312
434
|
const newEndpoints = [];
|
|
313
435
|
const unmatchedFiles = [];
|
|
314
436
|
for (const file of diffData.changedFiles) {
|
|
437
|
+
const fileKey = normalizeSourcePath(file);
|
|
315
438
|
// Deleted files won't appear in scanned catalog — handled separately below
|
|
316
|
-
if (deletedFileSet.has(
|
|
439
|
+
if (deletedFileSet.has(fileKey))
|
|
317
440
|
continue;
|
|
318
|
-
const eps = bySourceFile.get(
|
|
441
|
+
const eps = bySourceFile.get(fileKey);
|
|
319
442
|
if (!eps || eps.length === 0) {
|
|
320
443
|
unmatchedFiles.push(file);
|
|
321
444
|
continue;
|
|
322
445
|
}
|
|
323
|
-
if (newFileSet.has(
|
|
446
|
+
if (newFileSet.has(fileKey)) {
|
|
324
447
|
newEndpoints.push(...eps);
|
|
325
448
|
}
|
|
326
449
|
else {
|
|
@@ -328,23 +451,55 @@ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, dele
|
|
|
328
451
|
}
|
|
329
452
|
}
|
|
330
453
|
// Removed endpoints: from deleted files, recovered from the base branch.
|
|
331
|
-
// Filter out
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
338
499
|
const removedEndpoints = (deletedFileEndpoints ?? [])
|
|
339
500
|
.map((ep) => ({
|
|
340
501
|
...ep,
|
|
341
|
-
methods: ep.methods.filter((m) =>
|
|
342
|
-
// A deleted MULTI means the file was a catch-all (e.g. Next.js default export).
|
|
343
|
-
// If the path exists in the current catalog with any method, it was moved — not removed.
|
|
344
|
-
if (m === "MULTI")
|
|
345
|
-
return !currentPaths.has(ep.path);
|
|
346
|
-
return !currentEndpointKeys.has(`${m} ${ep.path}`) && !multiPaths.has(ep.path);
|
|
347
|
-
}),
|
|
502
|
+
methods: ep.methods.filter((m) => !isMovedEndpoint(ep, m)),
|
|
348
503
|
}))
|
|
349
504
|
.filter((ep) => ep.methods.length > 0);
|
|
350
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"],
|