@skyramp/mcp 0.2.8 → 0.2.10-rc.1

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 (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  // @ts-ignore
2
- import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles } from "./routeParsers.js";
2
+ import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles, selectRemovalCandidateFiles, recoverRemovedEndpointsFromBase, parseChangedLinesByFile } from "./routeParsers.js";
3
3
  describe("nextjsFileToApiPath", () => {
4
4
  it("converts pages/api route to API path", () => {
5
5
  expect(nextjsFileToApiPath("pages/api/users/index.ts")).toBe("/api/users");
@@ -147,8 +147,8 @@ describe("parseFileEndpoints", () => {
147
147
  }
148
148
  `;
149
149
  const eps = parseFileEndpoints(content, "UserController.java");
150
- expect(eps).toContainEqual({ method: "GET", path: "/api/v1/users/{id}", sourceFile: "UserController.java" });
151
- expect(eps).toContainEqual({ method: "POST", path: "/api/v1/users", sourceFile: "UserController.java" });
150
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/api/v1/users/{id}", sourceFile: "UserController.java" }));
151
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/api/v1/users", sourceFile: "UserController.java" }));
152
152
  });
153
153
  it("Spring: class without prefix emits method paths as-is", () => {
154
154
  const content = `
@@ -158,7 +158,7 @@ describe("parseFileEndpoints", () => {
158
158
  }
159
159
  `;
160
160
  const eps = parseFileEndpoints(content, "ProductController.java");
161
- expect(eps).toContainEqual({ method: "GET", path: "/products", sourceFile: "ProductController.java" });
161
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/products", sourceFile: "ProductController.java" }));
162
162
  });
163
163
  it("Spring: second class in file does not inherit first class prefix", () => {
164
164
  const content = `
@@ -173,9 +173,9 @@ describe("parseFileEndpoints", () => {
173
173
  }
174
174
  `;
175
175
  const eps = parseFileEndpoints(content, "Controllers.java");
176
- expect(eps).toContainEqual({ method: "GET", path: "/api/orders/{id}", sourceFile: "Controllers.java" });
177
- expect(eps).toContainEqual({ method: "GET", path: "/health", sourceFile: "Controllers.java" });
178
- expect(eps).not.toContainEqual({ method: "GET", path: "/api/orders/health", sourceFile: "Controllers.java" });
176
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/api/orders/{id}", sourceFile: "Controllers.java" }));
177
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/health", sourceFile: "Controllers.java" }));
178
+ expect(eps).not.toContainEqual(expect.objectContaining({ method: "GET", path: "/api/orders/health", sourceFile: "Controllers.java" }));
179
179
  });
180
180
  it("NestJS: combines @Controller prefix with @Get path", () => {
181
181
  const content = `
@@ -186,7 +186,7 @@ describe("parseFileEndpoints", () => {
186
186
  }
187
187
  `;
188
188
  const eps = parseFileEndpoints(content, "users.controller.ts");
189
- expect(eps).toContainEqual({ method: "GET", path: "users/:id", sourceFile: "users.controller.ts" });
189
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/users/:id", sourceFile: "users.controller.ts" }));
190
190
  });
191
191
  it("NestJS: @Post() with no path uses controller root", () => {
192
192
  const content = `
@@ -199,8 +199,8 @@ describe("parseFileEndpoints", () => {
199
199
  }
200
200
  `;
201
201
  const eps = parseFileEndpoints(content, "users.controller.ts");
202
- expect(eps).toContainEqual({ method: "POST", path: "users", sourceFile: "users.controller.ts" });
203
- expect(eps).toContainEqual({ method: "GET", path: "users", sourceFile: "users.controller.ts" });
202
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/users", sourceFile: "users.controller.ts" }));
203
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/users", sourceFile: "users.controller.ts" }));
204
204
  });
205
205
  it("ASP.NET: combines [Route] prefix with [HttpGet] path", () => {
206
206
  const content = `
@@ -214,8 +214,8 @@ describe("parseFileEndpoints", () => {
214
214
  }
215
215
  `;
216
216
  const eps = parseFileEndpoints(content, "OrdersController.cs");
217
- expect(eps).toContainEqual({ method: "GET", path: "api/v1/orders/{id}", sourceFile: "OrdersController.cs" });
218
- expect(eps).toContainEqual({ method: "POST", path: "api/v1/orders", sourceFile: "OrdersController.cs" });
217
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/api/v1/orders/{id}", sourceFile: "OrdersController.cs" }));
218
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/api/v1/orders", sourceFile: "OrdersController.cs" }));
219
219
  });
220
220
  it("non-class-based frameworks are unaffected (Express, Flask, Gin)", () => {
221
221
  const content = `
@@ -224,7 +224,7 @@ describe("parseFileEndpoints", () => {
224
224
  r.DELETE("/users/:id", deleteUser)
225
225
  `;
226
226
  const eps = parseFileEndpoints(content, "routes.ts");
227
- expect(eps).toContainEqual({ method: "GET", path: "/api/products", sourceFile: "routes.ts" });
227
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/api/products", sourceFile: "routes.ts" }));
228
228
  });
229
229
  it("FastAPI multi-line APIRouter prefix is combined with method decorators", () => {
230
230
  const content = `
@@ -243,9 +243,9 @@ async def get_order(): pass
243
243
  async def delete_order(): pass
244
244
  `;
245
245
  const eps = parseFileEndpoints(content, "routers/order.py");
246
- expect(eps).toContainEqual({ method: "POST", path: "/orders", sourceFile: "routers/order.py" });
247
- expect(eps).toContainEqual({ method: "GET", path: "/orders/{order_id}", sourceFile: "routers/order.py" });
248
- expect(eps).toContainEqual({ method: "DELETE", path: "/orders/{order_id}", sourceFile: "routers/order.py" });
246
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/orders", sourceFile: "routers/order.py" }));
247
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/orders/{order_id}", sourceFile: "routers/order.py" }));
248
+ expect(eps).toContainEqual(expect.objectContaining({ method: "DELETE", path: "/orders/{order_id}", sourceFile: "routers/order.py" }));
249
249
  });
250
250
  it("FastAPI single-line APIRouter prefix is combined with method decorators", () => {
251
251
  const content = `
@@ -255,7 +255,7 @@ router = APIRouter(prefix="/products", tags=["products"])
255
255
  async def get_product(): pass
256
256
  `;
257
257
  const eps = parseFileEndpoints(content, "routers/product.py");
258
- expect(eps).toContainEqual({ method: "GET", path: "/products/{product_id}", sourceFile: "routers/product.py" });
258
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/products/{product_id}", sourceFile: "routers/product.py" }));
259
259
  });
260
260
  it("FastAPI multi-line method decorators capture path arguments", () => {
261
261
  const content = `
@@ -277,8 +277,8 @@ async def delete_flow(): pass
277
277
  async def bulk_delete_flows(): pass
278
278
  `;
279
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" });
280
+ expect(eps).toContainEqual(expect.objectContaining({ method: "DELETE", path: "/flows/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" }));
281
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/flows/bulk_delete", sourceFile: "src/prefect/server/api/flows.py" }));
282
282
  });
283
283
  // ── Multi-line Express route patterns ──────────────────────────────────────
284
284
  it("Express multi-line: router.post( with path on next line", () => {
@@ -289,7 +289,7 @@ router.post(
289
289
  );
290
290
  `;
291
291
  const eps = parseFileEndpoints(content, "controllers/collections.ts");
292
- expect(eps).toContainEqual({ method: "POST", path: "/:collection", sourceFile: "controllers/collections.ts" });
292
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/:collection", sourceFile: "controllers/collections.ts" }));
293
293
  });
294
294
  it("Express multi-line: app.get( with path on next line", () => {
295
295
  const content = `
@@ -299,13 +299,13 @@ app.get(
299
299
  );
300
300
  `;
301
301
  const eps = parseFileEndpoints(content, "server.ts");
302
- expect(eps).toContainEqual({ method: "GET", path: "/health", sourceFile: "server.ts" });
302
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/health", sourceFile: "server.ts" }));
303
303
  });
304
304
  it("Express multi-line: does not fire for single-line routes", () => {
305
305
  const content = `router.get('/api/products', handler)`;
306
306
  const eps = parseFileEndpoints(content, "routes.ts");
307
307
  expect(eps).toHaveLength(1);
308
- expect(eps[0]).toEqual({ method: "GET", path: "/api/products", sourceFile: "routes.ts" });
308
+ expect(eps[0]).toEqual(expect.objectContaining({ method: "GET", path: "/api/products", sourceFile: "routes.ts" }));
309
309
  });
310
310
  it("Express multi-line: handles multiple consecutive multi-line routes", () => {
311
311
  const content = `
@@ -326,9 +326,9 @@ router.delete(
326
326
  `;
327
327
  const eps = parseFileEndpoints(content, "controllers/items.ts");
328
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" });
329
+ expect(eps).toContainEqual(expect.objectContaining({ method: "POST", path: "/", sourceFile: "controllers/items.ts" }));
330
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/:pk", sourceFile: "controllers/items.ts" }));
331
+ expect(eps).toContainEqual(expect.objectContaining({ method: "DELETE", path: "/:pk", sourceFile: "controllers/items.ts" }));
332
332
  });
333
333
  it("Express multi-line: ignores component files (.tsx)", () => {
334
334
  const content = `
@@ -348,7 +348,7 @@ router.get('/fallback', handler);
348
348
  `;
349
349
  const eps = parseFileEndpoints(content, "routes.ts");
350
350
  expect(eps).toHaveLength(1);
351
- expect(eps[0]).toEqual({ method: "GET", path: "/fallback", sourceFile: "routes.ts" });
351
+ expect(eps[0]).toEqual(expect.objectContaining({ method: "GET", path: "/fallback", sourceFile: "routes.ts" }));
352
352
  });
353
353
  it("Express multi-line: mixes single-line and multi-line routes", () => {
354
354
  const content = `
@@ -367,9 +367,9 @@ router.patch(
367
367
  `;
368
368
  const eps = parseFileEndpoints(content, "controllers/access.ts");
369
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" });
370
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/", sourceFile: "controllers/access.ts" }));
371
+ expect(eps).toContainEqual(expect.objectContaining({ method: "GET", path: "/:pk", sourceFile: "controllers/access.ts" }));
372
+ expect(eps).toContainEqual(expect.objectContaining({ method: "PATCH", path: "/:pk", sourceFile: "controllers/access.ts" }));
373
373
  });
374
374
  });
375
375
  describe("extractResourceFromPath", () => {
@@ -644,3 +644,253 @@ describe("classifyEndpointsByChangedFiles", () => {
644
644
  expect(result.removedEndpoints).toEqual([]);
645
645
  });
646
646
  });
647
+ describe("selectRemovalCandidateFiles", () => {
648
+ function makeDiff(opts) {
649
+ return {
650
+ currentBranch: "feature/test",
651
+ baseBranch: "origin/main",
652
+ changedFiles: opts.changedFiles,
653
+ diffContent: "",
654
+ diffStat: "",
655
+ newFiles: opts.newFiles ?? [],
656
+ deletedFiles: opts.deletedFiles ?? [],
657
+ };
658
+ }
659
+ it("includes modified and deleted files but excludes newly-created files", () => {
660
+ // A route removed by editing a surviving file lives in a *modified* file, not a
661
+ // deleted one — so modified files must be candidates for removed-endpoint recovery.
662
+ const diff = makeDiff({
663
+ changedFiles: ["src/routes/recipe.py", "src/routes/brand_new.py", "src/routes/legacy.py"],
664
+ newFiles: ["src/routes/brand_new.py"],
665
+ deletedFiles: ["src/routes/legacy.py"],
666
+ });
667
+ expect(selectRemovalCandidateFiles(diff).sort()).toEqual(["src/routes/legacy.py", "src/routes/recipe.py"].sort());
668
+ });
669
+ it("returns empty when the only change is a newly-created file", () => {
670
+ const diff = makeDiff({
671
+ changedFiles: ["src/routes/brand_new.py"],
672
+ newFiles: ["src/routes/brand_new.py"],
673
+ });
674
+ expect(selectRemovalCandidateFiles(diff)).toEqual([]);
675
+ });
676
+ });
677
+ describe("recoverRemovedEndpointsFromBase", () => {
678
+ it("recovers a route removed by editing a surviving file (line-edit removal)", async () => {
679
+ // Base version of a MODIFIED router file had two routes; the current branch
680
+ // deleted only /suggestions (the /bulk-actions route survives).
681
+ const baseContent = `
682
+ router = APIRouter(prefix="/api/recipes", tags=["recipes"])
683
+
684
+ @router.get("/suggestions")
685
+ async def suggest_recipes(): pass
686
+
687
+ @router.post("/bulk-actions/categorize")
688
+ async def categorize(): pass
689
+ `;
690
+ const fetchBaseContent = async (file) => file === "mealie/routes/recipe/recipe_crud_routes.py" ? baseContent : "";
691
+ const recovered = await recoverRemovedEndpointsFromBase(["mealie/routes/recipe/recipe_crud_routes.py"], fetchBaseContent);
692
+ // Both base-branch endpoints are parsed from the modified file.
693
+ expect(recovered.map((e) => e.path).sort()).toEqual(["/api/recipes/bulk-actions/categorize", "/api/recipes/suggestions"].sort());
694
+ // Feeding these to the classifier with the *current* catalog (which still has the
695
+ // surviving route) leaves only the genuinely-removed route in removedEndpoints.
696
+ const scanned = [
697
+ { path: "/api/recipes/bulk-actions/categorize", methods: ["POST"], sourceFile: "mealie/routes/recipe/recipe_crud_routes.py" },
698
+ ];
699
+ const diff = {
700
+ currentBranch: "feature/test",
701
+ baseBranch: "origin/main",
702
+ changedFiles: ["mealie/routes/recipe/recipe_crud_routes.py"],
703
+ diffContent: "",
704
+ diffStat: "",
705
+ newFiles: [],
706
+ deletedFiles: [],
707
+ };
708
+ const result = classifyEndpointsByChangedFiles(diff, scanned, recovered);
709
+ expect(result.removedEndpoints.map((e) => e.path)).toEqual(["/api/recipes/suggestions"]);
710
+ });
711
+ it("skips non-route files and tolerates fetch failures", async () => {
712
+ const fetchBaseContent = async (file) => {
713
+ if (file === "README.md")
714
+ throw new Error("should not be fetched");
715
+ throw new Error("git show failed");
716
+ };
717
+ const recovered = await recoverRemovedEndpointsFromBase(["README.md", "src/routes/orders.py"], fetchBaseContent);
718
+ expect(recovered).toEqual([]);
719
+ });
720
+ });
721
+ describe("parseFileEndpoints — declaration line numbers", () => {
722
+ it("records the 1-based declaration line of each single-line route", () => {
723
+ const content = [
724
+ "package main", // 1
725
+ "", // 2
726
+ "func Routes() {", // 3
727
+ ' r.GET("/users", h)', // 4
728
+ ' r.POST("/items", h)', // 5
729
+ "}", // 6
730
+ ].join("\n");
731
+ const eps = parseFileEndpoints(content, "main.go");
732
+ expect(eps.find((e) => e.path === "/users")?.line).toBe(4);
733
+ expect(eps.find((e) => e.path === "/items")?.line).toBe(5);
734
+ });
735
+ it("records the decorator start line for a multi-line FastAPI route", () => {
736
+ const content = [
737
+ "router = APIRouter()", // 1
738
+ "", // 2
739
+ "@router.get(", // 3 <- decorator start
740
+ ' "/flows",', // 4 <- path resolved here
741
+ ")", // 5
742
+ "def list_flows():", // 6
743
+ " return []", // 7
744
+ ].join("\n");
745
+ const eps = parseFileEndpoints(content, "api/flows.py");
746
+ expect(eps[0].path).toBe("/flows");
747
+ expect(eps[0].line).toBe(3);
748
+ });
749
+ });
750
+ describe("parseChangedLinesByFile", () => {
751
+ it("collects new-side line numbers of added lines per file", () => {
752
+ const diffContent = [
753
+ "diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go",
754
+ "--- a/routers/api/v1/api.go",
755
+ "+++ b/routers/api/v1/api.go",
756
+ "@@ -147,5 +147,6 @@ func Routes() {",
757
+ " m.Group(\"/misc\", func() {",
758
+ " existingA",
759
+ " existingB",
760
+ "+ m.Post(\"/markdown\", Markdown)",
761
+ " existingC",
762
+ " })",
763
+ ].join("\n");
764
+ const map = parseChangedLinesByFile(diffContent);
765
+ // new-side numbering from 147: 147 context, 148, 149, 150(+), 151, 152
766
+ expect([...(map.get("routers/api/v1/api.go") ?? [])]).toEqual([150]);
767
+ });
768
+ it("does not advance the new-side cursor on removed lines", () => {
769
+ const diffContent = [
770
+ "--- a/svc/orders.py",
771
+ "+++ b/svc/orders.py",
772
+ "@@ -10,4 +10,3 @@",
773
+ " keep",
774
+ "-gone",
775
+ "+added",
776
+ " keep2",
777
+ ].join("\n");
778
+ const map = parseChangedLinesByFile(diffContent);
779
+ // 10 context(keep), '-gone' no advance, '+added' at new line 11
780
+ expect([...(map.get("svc/orders.py") ?? [])]).toEqual([11]);
781
+ });
782
+ it("records the new-side position of a deletion-only hunk", () => {
783
+ const diffContent = [
784
+ "--- a/svc/orders.py",
785
+ "+++ b/svc/orders.py",
786
+ "@@ -20,3 +20,2 @@",
787
+ " keep",
788
+ "-gone",
789
+ " keep2",
790
+ ].join("\n");
791
+ const map = parseChangedLinesByFile(diffContent);
792
+ // 20 context(keep) -> cursor 21, '-gone' records 21 (no advance), keep2 -> 21
793
+ expect([...(map.get("svc/orders.py") ?? [])]).toEqual([21]);
794
+ });
795
+ });
796
+ describe("classifyEndpointsByChangedFiles — hunk-scoped (centralized routing)", () => {
797
+ // Gitea-shaped hub: every route registered in one api.go file, each on its own line.
798
+ const HUB = "routers/api/v1/api.go";
799
+ const hubEndpoints = [
800
+ { path: "/repos/{owner}/{repo}/issues", methods: ["GET", "POST"], sourceFile: HUB, line: 100 },
801
+ { path: "/markdown", methods: ["POST"], sourceFile: HUB, line: 150 },
802
+ { path: "/markup", methods: ["POST"], sourceFile: HUB, line: 151 },
803
+ { path: "/user/repos", methods: ["GET"], sourceFile: HUB, line: 200 },
804
+ ];
805
+ function diffAddingMarkdownRoute() {
806
+ // Adds one route line whose new-side line number is 150 (the /markdown decl).
807
+ return {
808
+ currentBranch: "feature/add-markdown",
809
+ baseBranch: "origin/main",
810
+ changedFiles: [HUB],
811
+ diffContent: [
812
+ `diff --git a/${HUB} b/${HUB}`,
813
+ `--- a/${HUB}`,
814
+ `+++ b/${HUB}`,
815
+ "@@ -147,5 +147,6 @@ func Routes() {",
816
+ " existingA",
817
+ " existingB",
818
+ " existingC",
819
+ "+ m.Post(\"/markdown\", Markdown)",
820
+ " existingD",
821
+ ].join("\n"),
822
+ diffStat: "",
823
+ newFiles: [],
824
+ deletedFiles: [],
825
+ };
826
+ }
827
+ it("scopes changed endpoints to the route line actually edited, not the whole hub file", () => {
828
+ const result = classifyEndpointsByChangedFiles(diffAddingMarkdownRoute(), hubEndpoints);
829
+ expect(result.changedEndpoints.map((e) => e.path)).toEqual(["/markdown"]);
830
+ });
831
+ it("attributes a handler-body edit to its enclosing route (decentralized, no over-scoping)", () => {
832
+ const recipe = [
833
+ { path: "/api/recipes", methods: ["GET", "POST"], sourceFile: "mealie/routes/recipe.py", line: 10 },
834
+ ];
835
+ const diff = {
836
+ currentBranch: "f", baseBranch: "origin/main",
837
+ changedFiles: ["mealie/routes/recipe.py"],
838
+ diffContent: [
839
+ "--- a/mealie/routes/recipe.py",
840
+ "+++ b/mealie/routes/recipe.py",
841
+ "@@ -24,2 +24,3 @@ def get_recipe():",
842
+ " x = 1",
843
+ "+ y = 2",
844
+ " return x",
845
+ ].join("\n"),
846
+ diffStat: "", newFiles: [], deletedFiles: [],
847
+ };
848
+ const result = classifyEndpointsByChangedFiles(diff, recipe);
849
+ expect(result.changedEndpoints.map((e) => e.path)).toEqual(["/api/recipes"]);
850
+ });
851
+ it("falls back to file-level scoping when endpoints carry no line info", () => {
852
+ const noLines = hubEndpoints.map(({ line: _line, ...rest }) => rest);
853
+ const result = classifyEndpointsByChangedFiles(diffAddingMarkdownRoute(), noLines);
854
+ expect(result.changedEndpoints).toHaveLength(4);
855
+ });
856
+ it("falls back to file-level scoping when diffContent is empty", () => {
857
+ const diff = { ...diffAddingMarkdownRoute(), diffContent: "" };
858
+ const result = classifyEndpointsByChangedFiles(diff, hubEndpoints);
859
+ expect(result.changedEndpoints).toHaveLength(4);
860
+ });
861
+ it("scopes a deletion-only edit to the route that owns the deleted position", () => {
862
+ // A hunk that only removes lines (no additions) must still scope — otherwise
863
+ // a deletion in a hub file falls back to flagging the whole route table.
864
+ const diff = {
865
+ currentBranch: "f", baseBranch: "origin/main",
866
+ changedFiles: [HUB],
867
+ diffContent: [
868
+ `+++ b/${HUB}`,
869
+ "@@ -158,3 +158,2 @@",
870
+ " ctxA",
871
+ "- m.Get(\"/markup-legacy\", MarkupLegacy)",
872
+ " ctxB",
873
+ ].join("\n"),
874
+ diffStat: "", newFiles: [], deletedFiles: [],
875
+ };
876
+ // Deleted position is new-side 159, inside /markup's ownership range [151, 200).
877
+ const result = classifyEndpointsByChangedFiles(diff, hubEndpoints);
878
+ expect(result.changedEndpoints.map((e) => e.path)).toEqual(["/markup"]);
879
+ });
880
+ it("falls back to file-level when the change is above the first route (e.g. imports)", () => {
881
+ const diff = {
882
+ currentBranch: "f", baseBranch: "origin/main",
883
+ changedFiles: [HUB],
884
+ diffContent: [
885
+ `+++ b/${HUB}`,
886
+ "@@ -1,2 +1,3 @@",
887
+ " package v1",
888
+ "+ import \"fmt\"",
889
+ " import \"net/http\"",
890
+ ].join("\n"),
891
+ diffStat: "", newFiles: [], deletedFiles: [],
892
+ };
893
+ const result = classifyEndpointsByChangedFiles(diff, hubEndpoints);
894
+ expect(result.changedEndpoints).toHaveLength(4);
895
+ });
896
+ });
@@ -184,11 +184,16 @@ function extractRoutesFromFile(filePath, cache) {
184
184
  return;
185
185
  }
186
186
  // 2. defineModule({ id, routes: [...] }) — Directus per-module aggregation.
187
+ // createBrowserRouter([...]) — React Router data-router object form.
187
188
  if (ts.isCallExpression(node)) {
188
189
  const moduleRoutes = defineModuleRoutesFromCall(node, filePath, imports, localConsts, cache);
189
190
  if (moduleRoutes) {
190
191
  routes.push(...moduleRoutes);
191
192
  }
193
+ const dataRouterRoutes = createRouterRoutesFromCall(node, filePath, imports, localConsts, cache);
194
+ if (dataRouterRoutes) {
195
+ routes.push(...dataRouterRoutes);
196
+ }
192
197
  }
193
198
  });
194
199
  // 3. Vue Router object-literal arrays (top-level, not inside defineModule).
@@ -203,21 +208,62 @@ function jsxRouteFromNode(node, filePath, imports, localConsts, cache) {
203
208
  return null;
204
209
  const attrs = jsxAttributesOf(node);
205
210
  const pathAttr = attrs.find((a) => a.name === "path");
206
- if (!pathAttr)
207
- return null;
208
- const pathValue = resolveAttrToString(pathAttr, filePath, imports, localConsts, cache);
209
- if (pathValue === null)
211
+ let ownPath;
212
+ if (pathAttr) {
213
+ ownPath = resolveAttrToString(pathAttr, filePath, imports, localConsts, cache);
214
+ if (ownPath === null)
215
+ return null;
216
+ }
217
+ else if (attrs.some((a) => a.name === "index")) {
218
+ // <Route index> has no path — it renders at the parent route's path.
219
+ ownPath = "";
220
+ }
221
+ else {
210
222
  return null;
223
+ }
211
224
  const componentAttr = attrs.find((a) => a.name === "element" || a.name === "component");
212
225
  if (!componentAttr || componentAttr.kind !== "identifier")
213
226
  return null;
227
+ // React Router child paths are relative to the enclosing <Route>; compose
228
+ // with the ancestor chain so nested routes yield full navigable URLs.
229
+ const parentPrefix = enclosingJsxRoutePath(node, filePath, imports, localConsts, cache);
214
230
  return {
215
- path: pathValue,
231
+ path: composeRoutePath(parentPrefix, ownPath),
216
232
  componentName: componentAttr.value,
217
233
  componentFile: imports.get(componentAttr.value)?.filePath,
218
234
  declaredIn: filePath,
219
235
  };
220
236
  }
237
+ /**
238
+ * Walk a JSX route node's ancestor chain and compose the paths of the enclosing
239
+ * `<Route>` elements (root → leaf). Returns "" for a top-level route with no
240
+ * enclosing route. Relies on `setParentNodes` being enabled when parsing.
241
+ */
242
+ function enclosingJsxRoutePath(node, filePath, imports, localConsts, cache) {
243
+ const segments = [];
244
+ let cur = node.parent;
245
+ while (cur) {
246
+ // A non-leaf <Route> is passed as its opening element, whose parent is its
247
+ // OWN JsxElement — skip that so the route doesn't count its own path.
248
+ if (ts.isJsxElement(cur) && cur.openingElement !== node) {
249
+ const opening = cur.openingElement;
250
+ const tag = jsxTagOf(opening);
251
+ if (tag && ROUTE_TAG_NAMES.has(tag)) {
252
+ const pathAttr = jsxAttributesOf(opening).find((a) => a.name === "path");
253
+ if (pathAttr) {
254
+ const p = resolveAttrToString(pathAttr, filePath, imports, localConsts, cache);
255
+ if (p !== null)
256
+ segments.unshift(p); // nearest ancestor seen first → prepend for root→leaf order
257
+ }
258
+ }
259
+ }
260
+ cur = cur.parent;
261
+ }
262
+ let prefix = "";
263
+ for (const seg of segments)
264
+ prefix = composeRoutePath(prefix, seg);
265
+ return prefix;
266
+ }
221
267
  function scriptKindFor(filePath) {
222
268
  const ext = path.extname(filePath).toLowerCase();
223
269
  switch (ext) {
@@ -682,6 +728,14 @@ function vueRouterRoutesFromFile(sf, filePath, imports, localConsts, cache) {
682
728
  return;
683
729
  if (isInsideDefineModule(node))
684
730
  return;
731
+ // createBrowserRouter objects are handled hierarchically (with children +
732
+ // path composition) by createRouterRoutesFromCall. Skip them here so a
733
+ // data-router object written in the { path, component } shape — which the
734
+ // Vue-style heuristic below also matches — isn't emitted a second time with
735
+ // an un-composed relative path. Objects using `element:` don't match that
736
+ // heuristic anyway, so this guard only bites the rarer `component:` form.
737
+ if (isInsideCreateRouter(node))
738
+ return;
685
739
  const r = routeFromObjectLiteral(node, "", filePath, imports, localConsts, cache);
686
740
  if (r)
687
741
  out.push(r);
@@ -700,6 +754,18 @@ function isInsideDefineModule(node) {
700
754
  }
701
755
  return false;
702
756
  }
757
+ function isInsideCreateRouter(node) {
758
+ let cur = node.parent;
759
+ while (cur) {
760
+ if (ts.isCallExpression(cur)) {
761
+ const expr = cur.expression;
762
+ if (ts.isIdentifier(expr) && DATA_ROUTER_FNS.has(expr.text))
763
+ return true;
764
+ }
765
+ cur = cur.parent;
766
+ }
767
+ return false;
768
+ }
703
769
  /**
704
770
  * Build a SourceRoute from an object literal that has the route shape
705
771
  * (`path` + `component` properties). Returns null when the shape doesn't
@@ -753,6 +819,109 @@ function composeRoutePath(prefix, child) {
753
819
  const normalizedChild = child.replace(/^\//, "");
754
820
  return normalizedPrefix + "/" + normalizedChild;
755
821
  }
822
+ // ── createBrowserRouter data-router API (React Router) ─────────────────────
823
+ /**
824
+ * Route-array factory functions from the React Router data-router API — all
825
+ * take the same `[{ path, element, children }]` array as their first argument.
826
+ * Includes the client routers (browser/hash/memory) and the SSR pair
827
+ * (createStaticRouter / createStaticHandler). The *Provider components
828
+ * (RouterProvider / StaticRouterProvider) are NOT here — they render a router
829
+ * instance via a `router` prop and declare no routes themselves.
830
+ */
831
+ const DATA_ROUTER_FNS = new Set([
832
+ "createBrowserRouter",
833
+ "createHashRouter",
834
+ "createMemoryRouter",
835
+ "createStaticRouter",
836
+ "createStaticHandler",
837
+ ]);
838
+ /**
839
+ * Detect a data-router factory call (`createBrowserRouter([...])` and the
840
+ * hash/memory/static variants) and yield routes from the object-literal array,
841
+ * recursing `children:` with the parent path composed in. Route objects use
842
+ * `element: <X/>` (JSX) or `component: X`, and `index: true` for the
843
+ * parent-path index route.
844
+ */
845
+ function createRouterRoutesFromCall(call, filePath, imports, localConsts, cache) {
846
+ const callee = call.expression;
847
+ if (!ts.isIdentifier(callee) || !DATA_ROUTER_FNS.has(callee.text))
848
+ return null;
849
+ const arg = call.arguments[0];
850
+ if (!arg || !ts.isArrayLiteralExpression(arg))
851
+ return null;
852
+ return dataRouterRoutesFromArray(arg, "", filePath, imports, localConsts, cache);
853
+ }
854
+ function dataRouterRoutesFromArray(arr, prefix, filePath, imports, localConsts, cache) {
855
+ const out = [];
856
+ for (const elt of arr.elements) {
857
+ if (!ts.isObjectLiteralExpression(elt))
858
+ continue;
859
+ let hasPath = false;
860
+ let pathValue = null;
861
+ let isIndex = false;
862
+ let componentName;
863
+ let childrenArray;
864
+ for (const prop of elt.properties) {
865
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
866
+ continue;
867
+ const name = prop.name.text;
868
+ if (name === "path") {
869
+ hasPath = true;
870
+ pathValue = resolveExpressionToString(prop.initializer, filePath, imports, localConsts, cache, 0);
871
+ }
872
+ else if (name === "index") {
873
+ isIndex = prop.initializer.kind === ts.SyntaxKind.TrueKeyword;
874
+ }
875
+ else if (name === "element" || name === "component") {
876
+ componentName = componentIdentifierOf(prop.initializer);
877
+ }
878
+ else if (name === "children" && ts.isArrayLiteralExpression(prop.initializer)) {
879
+ childrenArray = prop.initializer;
880
+ }
881
+ }
882
+ // A path present but unresolvable would make every descendant URL wrong —
883
+ // skip this object and its subtree rather than emit bogus paths.
884
+ if (hasPath && pathValue === null)
885
+ continue;
886
+ let childPrefix = prefix;
887
+ if (hasPath) {
888
+ childPrefix = composeRoutePath(prefix, pathValue);
889
+ if (componentName) {
890
+ out.push({
891
+ path: childPrefix,
892
+ componentName,
893
+ componentFile: imports.get(componentName)?.filePath,
894
+ declaredIn: filePath,
895
+ });
896
+ }
897
+ }
898
+ else if (isIndex && componentName) {
899
+ out.push({
900
+ path: composeRoutePath(prefix, ""),
901
+ componentName,
902
+ componentFile: imports.get(componentName)?.filePath,
903
+ declaredIn: filePath,
904
+ });
905
+ }
906
+ // Pathless layout route (no path, not index): contributes no URL segment;
907
+ // its children inherit the current prefix unchanged.
908
+ if (childrenArray) {
909
+ out.push(...dataRouterRoutesFromArray(childrenArray, childPrefix, filePath, imports, localConsts, cache));
910
+ }
911
+ }
912
+ return out;
913
+ }
914
+ /** Resolve a route object's `element`/`component` value to a component name. */
915
+ function componentIdentifierOf(init) {
916
+ if (ts.isIdentifier(init))
917
+ return init.text;
918
+ if (ts.isJsxSelfClosingElement(init) || ts.isJsxOpeningElement(init)) {
919
+ return jsxTagOf(init) ?? undefined;
920
+ }
921
+ if (ts.isJsxElement(init))
922
+ return jsxTagOf(init.openingElement) ?? undefined;
923
+ return undefined;
924
+ }
756
925
  // ── defineModule aggregation (Directus) ────────────────────────────────────
757
926
  /**
758
927
  * Detect `defineModule({ id: 'foo', routes: [...] })` calls and yield routes