@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.
Files changed (122) hide show
  1. package/build/index.js +4 -2
  2. package/build/playwright/registerPlaywrightTools.js +12 -0
  3. package/build/playwright/traceRecordingPrompt.js +15 -0
  4. package/build/prompts/code-reuse.js +106 -7
  5. package/build/prompts/pom-aware-code-reuse.js +106 -7
  6. package/build/prompts/startTraceCollectionPrompts.js +37 -15
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
  11. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
  12. package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
  13. package/build/prompts/test-recommendation/promptPlan.js +290 -0
  14. package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
  15. package/build/prompts/test-recommendation/recommendationSections.js +4 -3
  16. package/build/prompts/test-recommendation/recommendationShared.js +23 -1
  17. package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
  18. package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
  19. package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
  21. package/build/prompts/testbot/testbot-prompts.js +73 -13
  22. package/build/prompts/testbot/testbot-prompts.test.js +114 -1
  23. package/build/resources/testbotResource.js +1 -1
  24. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  25. package/build/services/ScenarioGenerationService.js +47 -4
  26. package/build/services/ScenarioGenerationService.test.js +158 -22
  27. package/build/services/TestExecutionService.js +73 -15
  28. package/build/services/TestExecutionService.test.js +105 -0
  29. package/build/services/TestGenerationService.js +11 -1
  30. package/build/tools/executeSkyrampTestTool.js +1 -10
  31. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  32. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  33. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  34. package/build/tools/test-management/actionsTool.js +152 -63
  35. package/build/tools/test-management/analyzeChangesTool.js +178 -64
  36. package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
  37. package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
  38. package/build/tools/test-management/index.js +1 -0
  39. package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
  40. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
  41. package/build/tools/trace/resolveSaveStoragePath.js +16 -0
  42. package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
  43. package/build/tools/trace/resolveSessionPaths.js +39 -0
  44. package/build/tools/trace/resolveSessionPaths.test.js +103 -0
  45. package/build/tools/trace/sessionState.js +14 -0
  46. package/build/tools/trace/sessionState.test.js +17 -0
  47. package/build/tools/trace/startTraceCollectionTool.js +84 -14
  48. package/build/tools/trace/stopTraceCollectionTool.js +9 -2
  49. package/build/types/TestAnalysis.js +50 -0
  50. package/build/types/TestRecommendation.js +6 -58
  51. package/build/types/TestTypes.js +1 -1
  52. package/build/utils/AnalysisStateManager.js +22 -11
  53. package/build/utils/branchDiff.js +11 -2
  54. package/build/utils/docker.test.js +1 -1
  55. package/build/utils/gitStaging.js +52 -3
  56. package/build/utils/gitStaging.test.js +19 -1
  57. package/build/utils/repoScanner.js +18 -10
  58. package/build/utils/repoScanner.test.js +92 -0
  59. package/build/utils/routeParsers.js +180 -25
  60. package/build/utils/routeParsers.test.js +180 -1
  61. package/build/utils/scenarioDrafting.js +220 -17
  62. package/build/utils/scenarioDrafting.test.js +182 -9
  63. package/build/utils/sourceRouteExtractor.js +806 -0
  64. package/build/utils/sourceRouteExtractor.test.js +565 -0
  65. package/build/utils/uiPageEnumerator.js +319 -0
  66. package/build/utils/uiPageEnumerator.test.js +422 -0
  67. package/build/utils/utils.js +27 -0
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.js +33 -4
  70. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  71. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  72. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
  73. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  74. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  75. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  76. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
  77. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
  78. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  79. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  80. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  81. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  82. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  83. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  84. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  85. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  86. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  87. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  88. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  89. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  90. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
  91. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
  92. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  93. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  94. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  95. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  96. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  97. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  98. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  99. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  100. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  101. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  102. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  103. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  104. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  105. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
  106. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
  107. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  108. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  109. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  110. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  111. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  112. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  113. package/node_modules/playwright/package.json +1 -1
  114. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  115. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  116. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  117. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
  118. package/package.json +3 -3
  119. package/build/services/TestHealthService.js +0 -694
  120. package/build/services/TestHealthService.test.js +0 -241
  121. package/build/types/TestDriftAnalysis.js +0 -1
  122. 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
- /** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
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
- return meaningful[meaningful.length - 1] || "unknown";
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 ScannedEndpoint[] map from the post-change catalog
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 existing = bySourceFile.get(ep.sourceFile);
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(ep.sourceFile, [ep]);
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(file))
439
+ if (deletedFileSet.has(fileKey))
317
440
  continue;
318
- const eps = bySourceFile.get(file);
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(file)) {
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 any endpoint that still exists in the current scanned catalog
332
- // (i.e. was moved/refactored to another file, not truly deleted).
333
- // Paths with a MULTI sentinel in the catalog are treated as present for all methods
334
- // (Next.js catch-all handlers, Java @RequestMapping without explicit method, etc.).
335
- const currentEndpointKeys = new Set(scannedEndpoints.flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)));
336
- const multiPaths = new Set(scannedEndpoints.filter((ep) => ep.methods.includes("MULTI")).map((ep) => ep.path));
337
- const currentPaths = new Set(scannedEndpoints.map((ep) => ep.path));
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("items");
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"],