@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.
- package/build/index.js +1 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +67 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +12 -10
- package/build/services/TestDiscoveryService.js +137 -83
- package/build/services/TestDiscoveryService.test.js +370 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +25 -25
- package/build/tools/submitReportTool.js +123 -34
- package/build/tools/submitReportTool.test.js +413 -94
- package/build/tools/test-management/actionsTool.js +177 -77
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +74 -169
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +18 -49
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +16 -22
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- 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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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:
|
|
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
|