@skyramp/mcp 0.1.8 → 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.js +4 -2
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +4 -3
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +180 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -0,0 +1,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
|
+
}
|