@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,806 @@
1
+ /**
2
+ * Source-grounded route extraction — strategy 2 of the candidate UI page ladder.
3
+ *
4
+ * Given a repo path, walks router/App files with the TypeScript Compiler API
5
+ * to find route declarations in three forms:
6
+ *
7
+ * 1. React-style JSX route elements:
8
+ * <Route path="/cart" element={<Cart/>} />
9
+ * <SentryRoute path={WORKSPACE_URL} component={Workspaces} />
10
+ *
11
+ * 2. Vue Router object literals:
12
+ * const routes = [{ path: '/login', component: Login }, …]
13
+ *
14
+ * 3. Directus `defineModule({ id, routes: [...] })`:
15
+ * defineModule({ id: 'notifications', routes: [{ path: '', component: X }] })
16
+ * → URL = "/notifications" (module id prefix + nested path)
17
+ *
18
+ * Path attribute values are resolved through:
19
+ * - String literals (`path="/cart"`)
20
+ * - Local const declarations (`const X = "/cart"; path={X}`)
21
+ * - Cross-file imports + named exports (`import { X } from './constants'`)
22
+ * - Template literals composed of resolvable parts (`` `${BASE}/foo` ``)
23
+ *
24
+ * Why TS Compiler API rather than regex: route declarations span multiple
25
+ * lines with attributes interleaved, paths reference constants that must be
26
+ * cross-file resolved, and tag names vary (Route, SentryRoute, PrivateRoute,
27
+ * …). The AST gives us the structure for free; regex would have to be
28
+ * deliberately permissive at the cost of false matches.
29
+ */
30
+ import * as fs from "fs";
31
+ import * as path from "path";
32
+ import * as ts from "typescript";
33
+ function makeParseCache() {
34
+ return { files: new Map(), tsconfigBaseUrl: new Map() };
35
+ }
36
+ /** Recursion cap for cross-file constant resolution. */
37
+ const MAX_RESOLVE_DEPTH = 4;
38
+ /**
39
+ * JSX tag names recognized as route declarations. Default covers React Router
40
+ * and SentryRoute (Sentry-instrumented wrapper used by appsmith). Wrapper
41
+ * components like PrivateRoute / AuthRoute / RoleRoute can be added here as
42
+ * we encounter them — the heuristic stays the same: a JSX element with `path`
43
+ * and `element`/`component` attrs is a route declaration.
44
+ */
45
+ const ROUTE_TAG_NAMES = new Set([
46
+ "Route",
47
+ "SentryRoute",
48
+ "PrivateRoute",
49
+ "PublicRoute",
50
+ "AuthRoute",
51
+ "ProtectedRoute",
52
+ ]);
53
+ /** Files we look for as router declaration sites. */
54
+ const ROUTER_FILE_GLOBS = [
55
+ "App.tsx",
56
+ "App.jsx",
57
+ "AppRouter.tsx",
58
+ "AppRouter.jsx",
59
+ "router.ts",
60
+ "router.tsx",
61
+ "router.js",
62
+ "router.jsx",
63
+ "routes.ts",
64
+ "routes.tsx",
65
+ "routes.js",
66
+ "routes.jsx",
67
+ "Routes.tsx",
68
+ "Routes.jsx",
69
+ ];
70
+ /** Cap on candidate files to avoid pathological repos. */
71
+ const MAX_CANDIDATE_FILES = 50;
72
+ /** Recursion cap on directory walking. */
73
+ const MAX_WALK_DEPTH = 8;
74
+ /** Directories we skip when walking the repo. */
75
+ const SKIP_DIRS = new Set([
76
+ "node_modules",
77
+ ".git",
78
+ "dist",
79
+ "build",
80
+ "out",
81
+ ".next",
82
+ ".nuxt",
83
+ ".svelte-kit",
84
+ "coverage",
85
+ "__tests__",
86
+ ]);
87
+ /**
88
+ * Walk the repo, parse candidate router files, return all route declarations.
89
+ *
90
+ * Best-effort by design: a parse error on one file shouldn't block discovery
91
+ * across the rest. Returns empty when no router files match — callers should
92
+ * treat that as "strategy 2 inapplicable" and fall through to the next one.
93
+ */
94
+ export function extractSourceRoutes(repositoryPath) {
95
+ const candidates = findRouterFiles(repositoryPath);
96
+ const parseCache = makeParseCache();
97
+ const routes = [];
98
+ for (const file of candidates) {
99
+ try {
100
+ const fileRoutes = extractRoutesFromFile(file, parseCache);
101
+ routes.push(...fileRoutes);
102
+ }
103
+ catch {
104
+ // Parse error or fs error — skip this file silently, keep going.
105
+ }
106
+ }
107
+ return routes;
108
+ }
109
+ function parseFile(filePath, cache) {
110
+ const cached = cache.files.get(filePath);
111
+ if (cached)
112
+ return cached;
113
+ try {
114
+ const source = fs.readFileSync(filePath, "utf-8");
115
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest,
116
+ /*setParentNodes*/ true, scriptKindFor(filePath));
117
+ cache.files.set(filePath, sf);
118
+ return sf;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ // ── File discovery ────────────────────────────────────────────────────────
125
+ function findRouterFiles(repoPath) {
126
+ const matches = [];
127
+ const targets = new Set(ROUTER_FILE_GLOBS.map((g) => g.toLowerCase()));
128
+ const indexExtensions = new Set([".ts", ".tsx", ".js", ".jsx"]);
129
+ function walk(dir, depth) {
130
+ if (depth > MAX_WALK_DEPTH)
131
+ return;
132
+ if (matches.length >= MAX_CANDIDATE_FILES)
133
+ return;
134
+ let entries;
135
+ try {
136
+ entries = fs.readdirSync(dir, { withFileTypes: true });
137
+ }
138
+ catch {
139
+ return;
140
+ }
141
+ for (const entry of entries) {
142
+ if (matches.length >= MAX_CANDIDATE_FILES)
143
+ return;
144
+ const full = path.join(dir, entry.name);
145
+ if (entry.isDirectory()) {
146
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
147
+ continue;
148
+ walk(full, depth + 1);
149
+ }
150
+ else if (entry.isFile()) {
151
+ const lower = entry.name.toLowerCase();
152
+ if (targets.has(lower)) {
153
+ matches.push(full);
154
+ continue;
155
+ }
156
+ // Directus-style modules: `…/modules/<name>/index.{ts,tsx,js,jsx}`
157
+ // hosts a `defineModule({ routes: [...] })` call.
158
+ if (lower.startsWith("index.")) {
159
+ const ext = path.extname(lower);
160
+ if (indexExtensions.has(ext) && full.includes(`${path.sep}modules${path.sep}`)) {
161
+ matches.push(full);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ walk(repoPath, 0);
168
+ return matches;
169
+ }
170
+ // ── Per-file extraction ────────────────────────────────────────────────────
171
+ function extractRoutesFromFile(filePath, cache) {
172
+ const sf = parseFile(filePath, cache);
173
+ if (!sf)
174
+ return [];
175
+ const imports = collectImports(sf, filePath, cache);
176
+ const localConsts = collectLocalConsts(sf);
177
+ const routes = [];
178
+ visit(sf, (node) => {
179
+ // 1. JSX route elements: <Route path="…" element={<X/>} />
180
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
181
+ const r = jsxRouteFromNode(node, filePath, imports, localConsts, cache);
182
+ if (r)
183
+ routes.push(r);
184
+ return;
185
+ }
186
+ // 2. defineModule({ id, routes: [...] }) — Directus per-module aggregation.
187
+ if (ts.isCallExpression(node)) {
188
+ const moduleRoutes = defineModuleRoutesFromCall(node, filePath, imports, localConsts, cache);
189
+ if (moduleRoutes) {
190
+ routes.push(...moduleRoutes);
191
+ }
192
+ }
193
+ });
194
+ // 3. Vue Router object-literal arrays (top-level, not inside defineModule).
195
+ // Walk separately so we can detect the "routes:" property context and skip
196
+ // arrays of unrelated config objects.
197
+ routes.push(...vueRouterRoutesFromFile(sf, filePath, imports, localConsts, cache));
198
+ return routes;
199
+ }
200
+ function jsxRouteFromNode(node, filePath, imports, localConsts, cache) {
201
+ const tag = jsxTagOf(node);
202
+ if (!tag || !ROUTE_TAG_NAMES.has(tag))
203
+ return null;
204
+ const attrs = jsxAttributesOf(node);
205
+ 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)
210
+ return null;
211
+ const componentAttr = attrs.find((a) => a.name === "element" || a.name === "component");
212
+ if (!componentAttr || componentAttr.kind !== "identifier")
213
+ return null;
214
+ return {
215
+ path: pathValue,
216
+ componentName: componentAttr.value,
217
+ componentFile: imports.get(componentAttr.value)?.filePath,
218
+ declaredIn: filePath,
219
+ };
220
+ }
221
+ function scriptKindFor(filePath) {
222
+ const ext = path.extname(filePath).toLowerCase();
223
+ switch (ext) {
224
+ case ".tsx":
225
+ return ts.ScriptKind.TSX;
226
+ case ".jsx":
227
+ return ts.ScriptKind.JSX;
228
+ case ".ts":
229
+ return ts.ScriptKind.TS;
230
+ case ".js":
231
+ return ts.ScriptKind.JS;
232
+ default:
233
+ return ts.ScriptKind.TSX;
234
+ }
235
+ }
236
+ function visit(node, fn) {
237
+ fn(node);
238
+ node.forEachChild((child) => visit(child, fn));
239
+ }
240
+ // ── JSX inspection ─────────────────────────────────────────────────────────
241
+ function jsxTagOf(node) {
242
+ const tag = node.tagName;
243
+ if (ts.isIdentifier(tag))
244
+ return tag.text;
245
+ // Member expression (e.g. <Foo.Bar/>) — not relevant for routes.
246
+ return null;
247
+ }
248
+ function jsxAttributesOf(node) {
249
+ const out = [];
250
+ for (const attr of node.attributes.properties) {
251
+ if (!ts.isJsxAttribute(attr))
252
+ continue;
253
+ const name = attr.name.getText();
254
+ const init = attr.initializer;
255
+ if (!init) {
256
+ out.push({ name, kind: "other", value: "" });
257
+ continue;
258
+ }
259
+ if (ts.isStringLiteral(init)) {
260
+ out.push({ name, kind: "string", value: init.text });
261
+ continue;
262
+ }
263
+ if (ts.isJsxExpression(init) && init.expression) {
264
+ out.push(parseExpressionAttr(name, init.expression));
265
+ continue;
266
+ }
267
+ out.push({ name, kind: "other", value: "" });
268
+ }
269
+ return out;
270
+ }
271
+ function parseExpressionAttr(name, expr) {
272
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
273
+ return { name, kind: "string", value: expr.text };
274
+ }
275
+ if (ts.isJsxSelfClosingElement(expr) || ts.isJsxOpeningElement(expr)) {
276
+ const tag = jsxTagOf(expr);
277
+ if (tag)
278
+ return { name, kind: "identifier", value: tag };
279
+ }
280
+ if (ts.isJsxElement(expr)) {
281
+ const tag = jsxTagOf(expr.openingElement);
282
+ if (tag)
283
+ return { name, kind: "identifier", value: tag };
284
+ }
285
+ if (ts.isIdentifier(expr)) {
286
+ return { name, kind: "identifier", value: expr.text };
287
+ }
288
+ // Template literals, binary expressions, etc. — defer to the resolver.
289
+ return { name, kind: "expression", value: "", expression: expr };
290
+ }
291
+ /**
292
+ * Map local name → imported binding info. Used to:
293
+ * - Resolve component identifiers to file paths (existing slice 3a use)
294
+ * - Resolve path identifiers across file boundaries (slice 3b new use)
295
+ *
296
+ * Handles:
297
+ * import Cart from './pages/Cart' // default, relative
298
+ * import { APPLICATIONS_URL } from './constants' // named, relative
299
+ * import { X as Y } from './foo' // aliased named
300
+ * import { WORKSPACE_URL } from "constants/routes" // baseUrl-resolved
301
+ *
302
+ * Bare imports (`react-router-dom`) are skipped — those aren't local files.
303
+ * `paths` aliases (e.g. `@/*`, `ee/*`) aren't currently honored; the only
304
+ * consequence in target repos is missing some constants in deep-aliased
305
+ * subtrees, which downgrades to "couldn't resolve, skip the route" — safe.
306
+ */
307
+ function collectImports(sf, filePath, cache) {
308
+ const result = new Map();
309
+ const fileDir = path.dirname(filePath);
310
+ const baseUrl = findTsconfigBaseUrl(fileDir, cache);
311
+ for (const stmt of sf.statements) {
312
+ if (!ts.isImportDeclaration(stmt))
313
+ continue;
314
+ const moduleSpec = stmt.moduleSpecifier;
315
+ if (!ts.isStringLiteral(moduleSpec))
316
+ continue;
317
+ const spec = moduleSpec.text;
318
+ let resolved;
319
+ if (spec.startsWith("/")) {
320
+ // Absolute import specs would let a hostile repo point the analyzer
321
+ // at arbitrary host paths during route extraction (e.g. /etc/passwd).
322
+ // Real codebases don't use literal absolute imports — they use
323
+ // relative paths or tsconfig path aliases.
324
+ continue;
325
+ }
326
+ if (spec.startsWith(".")) {
327
+ resolved = resolveImportPath(fileDir, spec);
328
+ }
329
+ else if (baseUrl) {
330
+ // Try baseUrl resolution for non-relative imports.
331
+ resolved = resolveImportPath(baseUrl, spec);
332
+ }
333
+ if (!resolved)
334
+ continue;
335
+ const clause = stmt.importClause;
336
+ if (!clause)
337
+ continue;
338
+ if (clause.name) {
339
+ result.set(clause.name.text, { filePath: resolved, exportedName: null });
340
+ }
341
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
342
+ for (const elt of clause.namedBindings.elements) {
343
+ // `import { X as Y }`: local name is `Y`, exported name is `X`.
344
+ // `import { X }`: local + exported are both `X`.
345
+ const exportedName = elt.propertyName?.text ?? elt.name.text;
346
+ result.set(elt.name.text, {
347
+ filePath: resolved,
348
+ exportedName,
349
+ });
350
+ }
351
+ }
352
+ }
353
+ return result;
354
+ }
355
+ /**
356
+ * Map local const names → their initializer expression. Used to resolve
357
+ * `const X = "/cart"; <Route path={X} />` within a single file, and to
358
+ * supply the source for cross-file constant lookups.
359
+ */
360
+ function collectLocalConsts(sf) {
361
+ const result = new Map();
362
+ for (const stmt of sf.statements) {
363
+ const decls = getVariableDeclarationList(stmt);
364
+ if (!decls)
365
+ continue;
366
+ for (const d of decls.declarations) {
367
+ if (!ts.isIdentifier(d.name))
368
+ continue;
369
+ if (!d.initializer)
370
+ continue;
371
+ result.set(d.name.text, d.initializer);
372
+ }
373
+ }
374
+ return result;
375
+ }
376
+ /**
377
+ * Variable declarations can appear at top level (`const X = ...`) or
378
+ * re-exported (`export const X = ...`). Both look the same to us.
379
+ */
380
+ function getVariableDeclarationList(stmt) {
381
+ if (ts.isVariableStatement(stmt))
382
+ return stmt.declarationList;
383
+ return null;
384
+ }
385
+ /**
386
+ * Resolve a parsed JSX/object-literal attribute to a concrete string value.
387
+ *
388
+ * Returns:
389
+ * - The string content for string-literal attrs.
390
+ * - The resolved string for identifier attrs (via local consts or imports).
391
+ * - The composed string for template literals whose parts all resolve.
392
+ * - `null` when resolution fails — caller should skip the route.
393
+ */
394
+ function resolveAttrToString(attr, filePath, imports, localConsts, cache) {
395
+ if (attr.kind === "string")
396
+ return attr.value;
397
+ if (attr.kind === "identifier") {
398
+ return resolveIdentifierToString(attr.value, filePath, imports, localConsts, cache, 0);
399
+ }
400
+ if (attr.kind === "expression" && attr.expression) {
401
+ return resolveExpressionToString(attr.expression, filePath, imports, localConsts, cache, 0);
402
+ }
403
+ return null;
404
+ }
405
+ /**
406
+ * Resolve an identifier to its concrete string value. Looks first in the
407
+ * current file's local consts; falls back to imports + cross-file lookup.
408
+ */
409
+ function resolveIdentifierToString(name, filePath, imports, localConsts, cache, depth) {
410
+ if (depth > MAX_RESOLVE_DEPTH)
411
+ return null;
412
+ const local = localConsts.get(name);
413
+ if (local) {
414
+ return resolveExpressionToString(local, filePath, imports, localConsts, cache, depth + 1);
415
+ }
416
+ const imp = imports.get(name);
417
+ if (imp) {
418
+ // exportedName === null marks a default import: `import Foo from './foo'`.
419
+ // The resolver looks up "default" specifically, so it can match either
420
+ // `export default <expr>` or `export { X as default }`.
421
+ return resolveExportedSymbolToString(imp.filePath, imp.exportedName ?? "default", cache, depth + 1);
422
+ }
423
+ return null;
424
+ }
425
+ /**
426
+ * Resolve an arbitrary expression to a string. Handles the forms commonly
427
+ * seen in route declarations: literals, identifiers, template literals
428
+ * composed of resolvable parts, simple binary `+` concatenation.
429
+ */
430
+ function resolveExpressionToString(expr, filePath, imports, localConsts, cache, depth) {
431
+ if (depth > MAX_RESOLVE_DEPTH)
432
+ return null;
433
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
434
+ return expr.text;
435
+ }
436
+ if (ts.isIdentifier(expr)) {
437
+ return resolveIdentifierToString(expr.text, filePath, imports, localConsts, cache, depth);
438
+ }
439
+ if (ts.isTemplateExpression(expr)) {
440
+ let out = expr.head.text;
441
+ for (const span of expr.templateSpans) {
442
+ const partVal = resolveExpressionToString(span.expression, filePath, imports, localConsts, cache, depth + 1);
443
+ if (partVal === null)
444
+ return null;
445
+ out += partVal + span.literal.text;
446
+ }
447
+ return out;
448
+ }
449
+ if (ts.isBinaryExpression(expr) &&
450
+ expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
451
+ const left = resolveExpressionToString(expr.left, filePath, imports, localConsts, cache, depth + 1);
452
+ if (left === null)
453
+ return null;
454
+ const right = resolveExpressionToString(expr.right, filePath, imports, localConsts, cache, depth + 1);
455
+ if (right === null)
456
+ return null;
457
+ return left + right;
458
+ }
459
+ return null;
460
+ }
461
+ /**
462
+ * Open a target file from cache, find the named export, resolve it.
463
+ * Used when the calling file imports a constant: `import { X } from './foo'`.
464
+ */
465
+ function resolveExportedSymbolToString(filePath, exportedName, cache, depth) {
466
+ if (depth > MAX_RESOLVE_DEPTH)
467
+ return null;
468
+ const sf = parseFile(filePath, cache);
469
+ if (!sf)
470
+ return null;
471
+ const imports = collectImports(sf, filePath, cache);
472
+ const localConsts = collectLocalConsts(sf);
473
+ const fileDir = path.dirname(filePath);
474
+ const baseUrl = findTsconfigBaseUrl(fileDir, cache);
475
+ // 1. Look for `export const X = …` directly in this file.
476
+ for (const stmt of sf.statements) {
477
+ if (!ts.isVariableStatement(stmt))
478
+ continue;
479
+ const isExported = !!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
480
+ if (!isExported)
481
+ continue;
482
+ for (const d of stmt.declarationList.declarations) {
483
+ if (!ts.isIdentifier(d.name))
484
+ continue;
485
+ if (d.name.text !== exportedName)
486
+ continue;
487
+ if (!d.initializer)
488
+ return null;
489
+ return resolveExpressionToString(d.initializer, filePath, imports, localConsts, cache, depth + 1);
490
+ }
491
+ }
492
+ // 1b. Handle `export default <expr>` and `export { X as default }` when
493
+ // the caller is resolving a default import.
494
+ if (exportedName === "default") {
495
+ for (const stmt of sf.statements) {
496
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
497
+ // `export default <expr>` — direct default export.
498
+ return resolveExpressionToString(stmt.expression, filePath, imports, localConsts, cache, depth + 1);
499
+ }
500
+ if (ts.isExportDeclaration(stmt) && stmt.exportClause &&
501
+ ts.isNamedExports(stmt.exportClause) && !stmt.moduleSpecifier) {
502
+ // `export { X as default }` — local re-export aliased as default.
503
+ for (const elt of stmt.exportClause.elements) {
504
+ if (elt.name.text !== "default")
505
+ continue;
506
+ const sourceName = elt.propertyName?.text ?? elt.name.text;
507
+ const local = localConsts.get(sourceName);
508
+ if (local) {
509
+ return resolveExpressionToString(local, filePath, imports, localConsts, cache, depth + 1);
510
+ }
511
+ // Could be re-exporting an imported binding: import X; export { X as default }.
512
+ const imp = imports.get(sourceName);
513
+ if (imp) {
514
+ return resolveExportedSymbolToString(imp.filePath, imp.exportedName ?? "default", cache, depth + 1);
515
+ }
516
+ }
517
+ }
518
+ }
519
+ }
520
+ // 2. Follow `export { X } from './foo'` and `export * from './foo'` re-exports.
521
+ for (const stmt of sf.statements) {
522
+ if (!ts.isExportDeclaration(stmt))
523
+ continue;
524
+ if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier))
525
+ continue;
526
+ const targetSpec = stmt.moduleSpecifier.text;
527
+ let targetFile;
528
+ if (targetSpec.startsWith("/")) {
529
+ // Absolute paths rejected for the same reason as collectImports.
530
+ continue;
531
+ }
532
+ if (targetSpec.startsWith(".")) {
533
+ targetFile = resolveImportPath(fileDir, targetSpec);
534
+ }
535
+ else if (baseUrl) {
536
+ targetFile = resolveImportPath(baseUrl, targetSpec);
537
+ }
538
+ if (!targetFile)
539
+ continue;
540
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
541
+ // `export { X as Y } from './foo'` — walk through with the original name.
542
+ for (const elt of stmt.exportClause.elements) {
543
+ if (elt.name.text !== exportedName)
544
+ continue;
545
+ const sourceName = elt.propertyName?.text ?? elt.name.text;
546
+ const v = resolveExportedSymbolToString(targetFile, sourceName, cache, depth + 1);
547
+ if (v !== null)
548
+ return v;
549
+ }
550
+ }
551
+ else if (!stmt.exportClause) {
552
+ // `export * from './foo'` — recurse with the original name.
553
+ const v = resolveExportedSymbolToString(targetFile, exportedName, cache, depth + 1);
554
+ if (v !== null)
555
+ return v;
556
+ }
557
+ }
558
+ return null;
559
+ }
560
+ const IMPORT_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js", ".vue"];
561
+ function resolveImportPath(fromDir, spec) {
562
+ const base = path.resolve(fromDir, spec);
563
+ // 1. Exact file (with the extension already in spec).
564
+ if (fileExistsSync(base))
565
+ return base;
566
+ // 2. Try each known extension.
567
+ for (const ext of IMPORT_EXTENSIONS) {
568
+ const candidate = base + ext;
569
+ if (fileExistsSync(candidate))
570
+ return candidate;
571
+ }
572
+ // 3. Treat as directory with index.{tsx,jsx,ts,js,vue}.
573
+ for (const ext of IMPORT_EXTENSIONS) {
574
+ const candidate = path.join(base, "index" + ext);
575
+ if (fileExistsSync(candidate))
576
+ return candidate;
577
+ }
578
+ return undefined;
579
+ }
580
+ function fileExistsSync(p) {
581
+ try {
582
+ const stat = fs.statSync(p);
583
+ return stat.isFile();
584
+ }
585
+ catch {
586
+ return false;
587
+ }
588
+ }
589
+ /**
590
+ * Walk parent directories until a `tsconfig.json` is found, then resolve its
591
+ * `baseUrl` (following one level of `extends` for the common case where
592
+ * tsconfig.json extends a tsconfig.path.json sibling). Returns null when no
593
+ * tsconfig is found or no `baseUrl` is configured.
594
+ *
595
+ * Memoized in the parse cache: cache hits AND misses, both keyed by the
596
+ * directory we started searching from. Prevents re-stat'ing the same parent
597
+ * chain on every import in a deeply nested tree.
598
+ */
599
+ function findTsconfigBaseUrl(fromDir, cache) {
600
+ const cached = cache.tsconfigBaseUrl.get(fromDir);
601
+ if (cached !== undefined)
602
+ return cached;
603
+ let cur = fromDir;
604
+ for (let i = 0; i < 12; i++) {
605
+ const tsconfigPath = path.join(cur, "tsconfig.json");
606
+ if (fileExistsSync(tsconfigPath)) {
607
+ const baseUrl = readTsconfigBaseUrl(tsconfigPath);
608
+ cache.tsconfigBaseUrl.set(fromDir, baseUrl);
609
+ return baseUrl;
610
+ }
611
+ const parent = path.dirname(cur);
612
+ if (parent === cur)
613
+ break;
614
+ cur = parent;
615
+ }
616
+ cache.tsconfigBaseUrl.set(fromDir, null);
617
+ return null;
618
+ }
619
+ function readTsconfigBaseUrl(tsconfigPath) {
620
+ // Cap recursion through `extends` to avoid pathological loops.
621
+ return readTsconfigBaseUrlInner(tsconfigPath, 0);
622
+ }
623
+ function readTsconfigBaseUrlInner(tsconfigPath, depth) {
624
+ if (depth > 4)
625
+ return null;
626
+ let parsed;
627
+ try {
628
+ const raw = fs.readFileSync(tsconfigPath, "utf-8");
629
+ // tsconfig allows comments; do a lenient parse.
630
+ parsed = parseJsonWithComments(raw);
631
+ }
632
+ catch {
633
+ return null;
634
+ }
635
+ const baseUrl = parsed?.compilerOptions?.baseUrl;
636
+ if (typeof baseUrl === "string") {
637
+ return path.resolve(path.dirname(tsconfigPath), baseUrl);
638
+ }
639
+ const extendsPath = parsed?.extends;
640
+ if (typeof extendsPath === "string") {
641
+ const tsconfigDir = path.dirname(tsconfigPath);
642
+ let extendedPath;
643
+ if (extendsPath.startsWith(".") || extendsPath.startsWith("/")) {
644
+ extendedPath = path.resolve(tsconfigDir, extendsPath);
645
+ }
646
+ else {
647
+ // Bare-extend (e.g. "@tsconfig/node20/tsconfig.json") — skip.
648
+ return null;
649
+ }
650
+ if (!extendedPath.endsWith(".json"))
651
+ extendedPath += ".json";
652
+ return readTsconfigBaseUrlInner(extendedPath, depth + 1);
653
+ }
654
+ return null;
655
+ }
656
+ /**
657
+ * Strip line/block comments from JSON before parsing. tsconfig.json files
658
+ * commonly contain comments; `JSON.parse` would reject them.
659
+ */
660
+ function parseJsonWithComments(raw) {
661
+ const stripped = raw
662
+ .replace(/\/\*[\s\S]*?\*\//g, "")
663
+ .replace(/^\s*\/\/.*$/gm, "");
664
+ return JSON.parse(stripped);
665
+ }
666
+ // ── Vue Router object literals ─────────────────────────────────────────────
667
+ /**
668
+ * Walk the file looking for object literals with both `path` and `component`
669
+ * properties — the Vue Router (and React Router data-API) shape:
670
+ *
671
+ * { path: '/login', component: Login }
672
+ *
673
+ * Skips object literals nested inside `defineModule({ routes: [...] })` calls
674
+ * — those are handled by `defineModuleRoutesFromCall` with the module-id
675
+ * prefix applied. Without this skip we'd emit the same routes twice with
676
+ * different paths.
677
+ */
678
+ function vueRouterRoutesFromFile(sf, filePath, imports, localConsts, cache) {
679
+ const out = [];
680
+ visit(sf, (node) => {
681
+ if (!ts.isObjectLiteralExpression(node))
682
+ return;
683
+ if (isInsideDefineModule(node))
684
+ return;
685
+ const r = routeFromObjectLiteral(node, "", filePath, imports, localConsts, cache);
686
+ if (r)
687
+ out.push(r);
688
+ });
689
+ return out;
690
+ }
691
+ function isInsideDefineModule(node) {
692
+ let cur = node.parent;
693
+ while (cur) {
694
+ if (ts.isCallExpression(cur)) {
695
+ const expr = cur.expression;
696
+ if (ts.isIdentifier(expr) && expr.text === "defineModule")
697
+ return true;
698
+ }
699
+ cur = cur.parent;
700
+ }
701
+ return false;
702
+ }
703
+ /**
704
+ * Build a SourceRoute from an object literal that has the route shape
705
+ * (`path` + `component` properties). Returns null when the shape doesn't
706
+ * match or path/component can't be resolved.
707
+ *
708
+ * `pathPrefix` is the module-id prefix from defineModule aggregation —
709
+ * empty string for top-level routes, "/notifications" for module routes.
710
+ */
711
+ function routeFromObjectLiteral(obj, pathPrefix, filePath, imports, localConsts, cache) {
712
+ let pathExpr;
713
+ let componentName;
714
+ for (const prop of obj.properties) {
715
+ if (!ts.isPropertyAssignment(prop))
716
+ continue;
717
+ if (!ts.isIdentifier(prop.name))
718
+ continue;
719
+ const propName = prop.name.text;
720
+ if (propName === "path") {
721
+ pathExpr = prop.initializer;
722
+ }
723
+ else if (propName === "component") {
724
+ const init = prop.initializer;
725
+ if (ts.isIdentifier(init))
726
+ componentName = init.text;
727
+ // Lazy imports `() => import('./pages/X')` are common but require their
728
+ // own resolution path; skip for now.
729
+ }
730
+ }
731
+ if (!pathExpr || !componentName)
732
+ return null;
733
+ const pathValue = resolveExpressionToString(pathExpr, filePath, imports, localConsts, cache, 0);
734
+ if (pathValue === null)
735
+ return null;
736
+ const composed = composeRoutePath(pathPrefix, pathValue);
737
+ return {
738
+ path: composed,
739
+ componentName,
740
+ componentFile: imports.get(componentName)?.filePath,
741
+ declaredIn: filePath,
742
+ };
743
+ }
744
+ function composeRoutePath(prefix, child) {
745
+ // Empty child path inside a module → prefix is the URL.
746
+ if (child === "")
747
+ return prefix || "/";
748
+ // Child already absolute → ignore the prefix (matches Vue Router semantics).
749
+ if (child.startsWith("/"))
750
+ return child;
751
+ // Compose: ensure exactly one slash between prefix and child.
752
+ const normalizedPrefix = prefix.replace(/\/$/, "");
753
+ const normalizedChild = child.replace(/^\//, "");
754
+ return normalizedPrefix + "/" + normalizedChild;
755
+ }
756
+ // ── defineModule aggregation (Directus) ────────────────────────────────────
757
+ /**
758
+ * Detect `defineModule({ id: 'foo', routes: [...] })` calls and yield routes
759
+ * from the nested array, prefixed with `/<id>`.
760
+ *
761
+ * This handles the Directus extension shape:
762
+ *
763
+ * defineModule({
764
+ * id: 'notifications',
765
+ * routes: [{ path: '', component: NotificationsCollection }],
766
+ * })
767
+ *
768
+ * → emits one SourceRoute with path `/notifications`.
769
+ */
770
+ function defineModuleRoutesFromCall(call, filePath, imports, localConsts, cache) {
771
+ const callee = call.expression;
772
+ if (!ts.isIdentifier(callee) || callee.text !== "defineModule")
773
+ return null;
774
+ const arg = call.arguments[0];
775
+ if (!arg || !ts.isObjectLiteralExpression(arg))
776
+ return null;
777
+ let moduleId;
778
+ let routesArray;
779
+ for (const prop of arg.properties) {
780
+ if (!ts.isPropertyAssignment(prop))
781
+ continue;
782
+ if (!ts.isIdentifier(prop.name))
783
+ continue;
784
+ const propName = prop.name.text;
785
+ if (propName === "id") {
786
+ const idVal = resolveExpressionToString(prop.initializer, filePath, imports, localConsts, cache, 0);
787
+ if (idVal !== null)
788
+ moduleId = idVal;
789
+ }
790
+ else if (propName === "routes" && ts.isArrayLiteralExpression(prop.initializer)) {
791
+ routesArray = prop.initializer;
792
+ }
793
+ }
794
+ if (!moduleId || !routesArray)
795
+ return null;
796
+ const prefix = "/" + moduleId.replace(/^\/+/, "");
797
+ const out = [];
798
+ for (const elt of routesArray.elements) {
799
+ if (!ts.isObjectLiteralExpression(elt))
800
+ continue;
801
+ const r = routeFromObjectLiteral(elt, prefix, filePath, imports, localConsts, cache);
802
+ if (r)
803
+ out.push(r);
804
+ }
805
+ return out;
806
+ }