@wtdlee/repomap 0.1.0
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/LICENSE +21 -0
- package/README.md +527 -0
- package/dist/analyzers/base-analyzer.d.ts +46 -0
- package/dist/analyzers/base-analyzer.d.ts.map +1 -0
- package/dist/analyzers/base-analyzer.js +48 -0
- package/dist/analyzers/base-analyzer.js.map +1 -0
- package/dist/analyzers/dataflow-analyzer.d.ts +30 -0
- package/dist/analyzers/dataflow-analyzer.d.ts.map +1 -0
- package/dist/analyzers/dataflow-analyzer.js +426 -0
- package/dist/analyzers/dataflow-analyzer.js.map +1 -0
- package/dist/analyzers/graphql-analyzer.d.ts +23 -0
- package/dist/analyzers/graphql-analyzer.d.ts.map +1 -0
- package/dist/analyzers/graphql-analyzer.js +387 -0
- package/dist/analyzers/graphql-analyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +6 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +6 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/analyzers/pages-analyzer.d.ts +85 -0
- package/dist/analyzers/pages-analyzer.d.ts.map +1 -0
- package/dist/analyzers/pages-analyzer.js +1696 -0
- package/dist/analyzers/pages-analyzer.js.map +1 -0
- package/dist/analyzers/rails/index.d.ts +47 -0
- package/dist/analyzers/rails/index.d.ts.map +1 -0
- package/dist/analyzers/rails/index.js +146 -0
- package/dist/analyzers/rails/index.js.map +1 -0
- package/dist/analyzers/rails/rails-controller-analyzer.d.ts +83 -0
- package/dist/analyzers/rails/rails-controller-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-controller-analyzer.js +479 -0
- package/dist/analyzers/rails/rails-controller-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.d.ts +45 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.js +263 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-model-analyzer.d.ts +89 -0
- package/dist/analyzers/rails/rails-model-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-model-analyzer.js +494 -0
- package/dist/analyzers/rails/rails-model-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-react-analyzer.d.ts +42 -0
- package/dist/analyzers/rails/rails-react-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-react-analyzer.js +530 -0
- package/dist/analyzers/rails/rails-react-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-routes-analyzer.d.ts +63 -0
- package/dist/analyzers/rails/rails-routes-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-routes-analyzer.js +541 -0
- package/dist/analyzers/rails/rails-routes-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-view-analyzer.d.ts +50 -0
- package/dist/analyzers/rails/rails-view-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-view-analyzer.js +387 -0
- package/dist/analyzers/rails/rails-view-analyzer.js.map +1 -0
- package/dist/analyzers/rails/ruby-parser.d.ts +64 -0
- package/dist/analyzers/rails/ruby-parser.d.ts.map +1 -0
- package/dist/analyzers/rails/ruby-parser.js +213 -0
- package/dist/analyzers/rails/ruby-parser.js.map +1 -0
- package/dist/analyzers/rest-api-analyzer.d.ts +66 -0
- package/dist/analyzers/rest-api-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rest-api-analyzer.js +480 -0
- package/dist/analyzers/rest-api-analyzer.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +550 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/cache.d.ts +48 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +152 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/engine.d.ts +47 -0
- package/dist/core/engine.d.ts.map +1 -0
- package/dist/core/engine.js +320 -0
- package/dist/core/engine.js.map +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/generators/assets/common.css +187 -0
- package/dist/generators/assets/docs.css +363 -0
- package/dist/generators/assets/page-map.css +305 -0
- package/dist/generators/assets/rails-map.css +473 -0
- package/dist/generators/index.d.ts +4 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +4 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/markdown-generator.d.ts +26 -0
- package/dist/generators/markdown-generator.d.ts.map +1 -0
- package/dist/generators/markdown-generator.js +783 -0
- package/dist/generators/markdown-generator.js.map +1 -0
- package/dist/generators/mermaid-generator.d.ts +36 -0
- package/dist/generators/mermaid-generator.d.ts.map +1 -0
- package/dist/generators/mermaid-generator.js +365 -0
- package/dist/generators/mermaid-generator.js.map +1 -0
- package/dist/generators/page-map-generator.d.ts +23 -0
- package/dist/generators/page-map-generator.d.ts.map +1 -0
- package/dist/generators/page-map-generator.js +3563 -0
- package/dist/generators/page-map-generator.js.map +1 -0
- package/dist/generators/rails-map-generator.d.ts +22 -0
- package/dist/generators/rails-map-generator.d.ts.map +1 -0
- package/dist/generators/rails-map-generator.js +909 -0
- package/dist/generators/rails-map-generator.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/server/doc-server.d.ts +31 -0
- package/dist/server/doc-server.d.ts.map +1 -0
- package/dist/server/doc-server.js +1233 -0
- package/dist/server/doc-server.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/env-detector.d.ts +32 -0
- package/dist/utils/env-detector.d.ts.map +1 -0
- package/dist/utils/env-detector.js +189 -0
- package/dist/utils/env-detector.js.map +1 -0
- package/dist/utils/parallel.d.ts +24 -0
- package/dist/utils/parallel.d.ts.map +1 -0
- package/dist/utils/parallel.js +71 -0
- package/dist/utils/parallel.js.map +1 -0
- package/package.json +131 -0
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
import { Project, SyntaxKind, Node } from 'ts-morph';
|
|
2
|
+
import fg from 'fast-glob';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { BaseAnalyzer } from './base-analyzer.js';
|
|
6
|
+
import { parallelMapSafe } from '../utils/parallel.js';
|
|
7
|
+
/**
|
|
8
|
+
* Analyzer for Next.js pages
|
|
9
|
+
* Next.jsページの分析器
|
|
10
|
+
*/
|
|
11
|
+
export class PagesAnalyzer extends BaseAnalyzer {
|
|
12
|
+
project;
|
|
13
|
+
// Codegen Document → Operation name mapping
|
|
14
|
+
codegenMap = new Map();
|
|
15
|
+
constructor(config) {
|
|
16
|
+
super(config);
|
|
17
|
+
this.project = new Project({
|
|
18
|
+
tsConfigFilePath: this.resolvePath('tsconfig.json'),
|
|
19
|
+
skipAddingFilesFromTsConfig: true,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract GraphQL operation name from a gql template literal or function call
|
|
24
|
+
* Handles multiple patterns:
|
|
25
|
+
* - gql`query Name { ... }`
|
|
26
|
+
* - gql(/* GraphQL */ `query Name { ... }`)
|
|
27
|
+
* - graphql(`query Name { ... }`)
|
|
28
|
+
*/
|
|
29
|
+
extractOperationNameFromGql(text) {
|
|
30
|
+
// Pattern 1: Direct operation keyword with name
|
|
31
|
+
const directMatch = text.match(/(?:query|mutation|subscription)\s+(\w+)/);
|
|
32
|
+
if (directMatch && directMatch[1]) {
|
|
33
|
+
return directMatch[1];
|
|
34
|
+
}
|
|
35
|
+
// Pattern 2: After backtick (with possible whitespace/newlines)
|
|
36
|
+
const backtickMatch = text.match(/`\s*(?:query|mutation|subscription)\s+(\w+)/);
|
|
37
|
+
if (backtickMatch && backtickMatch[1]) {
|
|
38
|
+
return backtickMatch[1];
|
|
39
|
+
}
|
|
40
|
+
// Pattern 3: After GraphQL comment
|
|
41
|
+
const commentMatch = text.match(/GraphQL[^`]*`\s*(?:\n\s*)?(?:query|mutation|subscription)\s+(\w+)/);
|
|
42
|
+
if (commentMatch && commentMatch[1]) {
|
|
43
|
+
return commentMatch[1];
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Find and extract operation name from a variable declaration in source file
|
|
49
|
+
* Handles cases like: const Query = gql(comment `query ActualName...`)
|
|
50
|
+
*/
|
|
51
|
+
findOperationNameFromVariable(sourceFile, variableName) {
|
|
52
|
+
// Method 1: Try to find variable declaration directly
|
|
53
|
+
let varDecl = sourceFile.getVariableDeclaration(variableName);
|
|
54
|
+
// Method 2: Try exported declarations
|
|
55
|
+
if (!varDecl) {
|
|
56
|
+
const exportedDecls = sourceFile.getExportedDeclarations();
|
|
57
|
+
const exported = exportedDecls.get(variableName);
|
|
58
|
+
if (exported && exported.length > 0) {
|
|
59
|
+
const firstExport = exported[0];
|
|
60
|
+
if (Node.isVariableDeclaration(firstExport)) {
|
|
61
|
+
varDecl = firstExport;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Method 3: Search all variable declarations
|
|
66
|
+
if (!varDecl) {
|
|
67
|
+
const allVarDecls = sourceFile.getVariableDeclarations();
|
|
68
|
+
varDecl = allVarDecls.find((v) => v.getName() === variableName);
|
|
69
|
+
}
|
|
70
|
+
// Method 4: Full text search for pattern (most comprehensive)
|
|
71
|
+
// This handles cases where the variable might not be found through AST
|
|
72
|
+
if (!varDecl) {
|
|
73
|
+
const fullText = sourceFile.getFullText();
|
|
74
|
+
// Match: const Query = gql(/* GraphQL */ `query ActualName...
|
|
75
|
+
// Or: const Query = gql`query ActualName...
|
|
76
|
+
const patterns = [
|
|
77
|
+
// gql(/* GraphQL */ `query Name...
|
|
78
|
+
new RegExp(`(?:const|let|var)\\s+${variableName}\\s*=\\s*gql\\s*\\(\\s*/\\*[^*]*\\*/\\s*\`\\s*(?:query|mutation|subscription)\\s+(\\w+)`, 's'),
|
|
79
|
+
// gql`query Name...
|
|
80
|
+
new RegExp(`(?:const|let|var)\\s+${variableName}\\s*=\\s*gql\`\\s*(?:query|mutation|subscription)\\s+(\\w+)`, 's'),
|
|
81
|
+
// gql(`query Name... (without comment)
|
|
82
|
+
new RegExp(`(?:const|let|var)\\s+${variableName}\\s*=\\s*gql\\s*\\(\`\\s*(?:query|mutation|subscription)\\s+(\\w+)`, 's'),
|
|
83
|
+
// graphql(`query Name...
|
|
84
|
+
new RegExp(`(?:const|let|var)\\s+${variableName}\\s*=\\s*graphql\\s*\\(\`\\s*(?:query|mutation|subscription)\\s+(\\w+)`, 's'),
|
|
85
|
+
];
|
|
86
|
+
for (const pattern of patterns) {
|
|
87
|
+
const match = fullText.match(pattern);
|
|
88
|
+
if (match && match[1]) {
|
|
89
|
+
return match[1];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
// Extract from initializer
|
|
95
|
+
const initializer = varDecl.getInitializer();
|
|
96
|
+
if (!initializer)
|
|
97
|
+
return null;
|
|
98
|
+
const text = initializer.getText();
|
|
99
|
+
return this.extractOperationNameFromGql(text);
|
|
100
|
+
}
|
|
101
|
+
getName() {
|
|
102
|
+
return 'PagesAnalyzer';
|
|
103
|
+
}
|
|
104
|
+
async analyze() {
|
|
105
|
+
this.log('Starting page analysis...');
|
|
106
|
+
// Load codegen mapping if available
|
|
107
|
+
await this.loadCodegenMapping();
|
|
108
|
+
// Analyze _app.tsx for global providers/context
|
|
109
|
+
await this.analyzeAppFile();
|
|
110
|
+
// Find page files from multiple possible locations
|
|
111
|
+
const pageFiles = await this.findPageFiles();
|
|
112
|
+
this.log(`Found ${pageFiles.length} page files`);
|
|
113
|
+
// Add all files to project first (sequential for ts-morph safety)
|
|
114
|
+
for (const filePath of pageFiles) {
|
|
115
|
+
try {
|
|
116
|
+
this.project.addSourceFileAtPath(filePath);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Ignore files that can't be added
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Analyze pages in parallel (using already-added source files)
|
|
123
|
+
const pages = await parallelMapSafe(pageFiles, async (filePath) => {
|
|
124
|
+
// Determine the correct pagesPath based on the file location
|
|
125
|
+
const pagesPath = this.detectPagesRoot(filePath);
|
|
126
|
+
return this.analyzePageFile(filePath, pagesPath);
|
|
127
|
+
}, 4 // Limit concurrency for ts-morph stability
|
|
128
|
+
);
|
|
129
|
+
// Filter out null results
|
|
130
|
+
const validPages = pages.filter((p) => p !== null);
|
|
131
|
+
this.log(`Analyzed ${validPages.length} pages successfully`);
|
|
132
|
+
return { pages: validPages };
|
|
133
|
+
}
|
|
134
|
+
async analyzePageFile(filePath, pagesPath) {
|
|
135
|
+
const sourceFile = this.project.getSourceFile(filePath);
|
|
136
|
+
if (!sourceFile)
|
|
137
|
+
return null;
|
|
138
|
+
const relativePath = path.relative(pagesPath, filePath);
|
|
139
|
+
const routePath = this.filePathToRoutePath(relativePath);
|
|
140
|
+
// Extract page component
|
|
141
|
+
const pageComponent = this.findPageComponent(sourceFile);
|
|
142
|
+
if (!pageComponent) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// Extract various page information
|
|
146
|
+
const params = this.extractRouteParams(routePath);
|
|
147
|
+
const layout = this.extractLayout(sourceFile);
|
|
148
|
+
const authentication = this.extractAuthRequirement(sourceFile);
|
|
149
|
+
const permissions = this.extractPermissions(sourceFile);
|
|
150
|
+
const dataFetching = this.extractDataFetching(sourceFile);
|
|
151
|
+
const navigation = this.extractNavigation(sourceFile);
|
|
152
|
+
const linkedPages = this.extractLinkedPages(sourceFile);
|
|
153
|
+
const steps = this.extractSteps(sourceFile);
|
|
154
|
+
return {
|
|
155
|
+
path: routePath,
|
|
156
|
+
filePath: relativePath,
|
|
157
|
+
component: pageComponent,
|
|
158
|
+
params,
|
|
159
|
+
layout,
|
|
160
|
+
authentication,
|
|
161
|
+
permissions,
|
|
162
|
+
dataFetching,
|
|
163
|
+
navigation,
|
|
164
|
+
linkedPages,
|
|
165
|
+
steps: steps.length > 0 ? steps : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Detect the pages root directory from a file path
|
|
170
|
+
* e.g., /project/src/pages/users/index.tsx -> /project/src/pages
|
|
171
|
+
*/
|
|
172
|
+
detectPagesRoot(filePath) {
|
|
173
|
+
// Common pages directory patterns to look for (exclude components/pages - those are reusable components)
|
|
174
|
+
const pagesPatterns = [
|
|
175
|
+
'/src/pages/',
|
|
176
|
+
'/pages/',
|
|
177
|
+
'/src/app/',
|
|
178
|
+
'/app/',
|
|
179
|
+
'/frontend/src/pages/',
|
|
180
|
+
'/app/javascript/pages/',
|
|
181
|
+
];
|
|
182
|
+
for (const pattern of pagesPatterns) {
|
|
183
|
+
const idx = filePath.indexOf(pattern);
|
|
184
|
+
if (idx !== -1) {
|
|
185
|
+
// Return the path up to and including the pages directory
|
|
186
|
+
return filePath.substring(0, idx + pattern.length - 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Fallback: use basePath
|
|
190
|
+
return this.basePath;
|
|
191
|
+
}
|
|
192
|
+
filePathToRoutePath(filePath) {
|
|
193
|
+
return ('/' +
|
|
194
|
+
filePath
|
|
195
|
+
.replace(/\.tsx?$/, '')
|
|
196
|
+
.replace(/\/index$/, '')
|
|
197
|
+
.replace(/\[\.\.\.(\w+)\]/g, '*')
|
|
198
|
+
.replace(/\[(\w+)\]/g, ':$1'));
|
|
199
|
+
}
|
|
200
|
+
extractRouteParams(routePath) {
|
|
201
|
+
const params = [];
|
|
202
|
+
const paramRegex = /:(\w+)/g;
|
|
203
|
+
let match;
|
|
204
|
+
while ((match = paramRegex.exec(routePath)) !== null) {
|
|
205
|
+
params.push(match[1]);
|
|
206
|
+
}
|
|
207
|
+
return params;
|
|
208
|
+
}
|
|
209
|
+
findPageComponent(sourceFile) {
|
|
210
|
+
// Find default export
|
|
211
|
+
const defaultExport = sourceFile.getDefaultExportSymbol();
|
|
212
|
+
if (defaultExport) {
|
|
213
|
+
const name = defaultExport.getName();
|
|
214
|
+
// Handle 'default' export name
|
|
215
|
+
if (name !== 'default') {
|
|
216
|
+
return name;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Check for export default function/const
|
|
220
|
+
const exportAssignment = sourceFile.getExportAssignment((e) => !e.isExportEquals());
|
|
221
|
+
if (exportAssignment) {
|
|
222
|
+
const expr = exportAssignment.getExpression();
|
|
223
|
+
if (expr) {
|
|
224
|
+
const text = expr.getText();
|
|
225
|
+
// If it's a simple identifier, return it
|
|
226
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(text)) {
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
// If it's a function expression, try to find the function name
|
|
230
|
+
if (Node.isFunctionExpression(expr) || Node.isArrowFunction(expr)) {
|
|
231
|
+
return 'default';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Find export default function declaration
|
|
236
|
+
const functions = sourceFile.getFunctions();
|
|
237
|
+
for (const func of functions) {
|
|
238
|
+
if (func.isDefaultExport()) {
|
|
239
|
+
return func.getName() || 'default';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Find Page variable
|
|
243
|
+
const pageVar = sourceFile.getVariableDeclaration('Page');
|
|
244
|
+
if (pageVar) {
|
|
245
|
+
return 'Page';
|
|
246
|
+
}
|
|
247
|
+
// Find NextPage typed variable (even without default export)
|
|
248
|
+
const varDeclarations = sourceFile.getVariableDeclarations();
|
|
249
|
+
for (const varDecl of varDeclarations) {
|
|
250
|
+
const typeNode = varDecl.getTypeNode();
|
|
251
|
+
if (typeNode) {
|
|
252
|
+
const typeText = typeNode.getText();
|
|
253
|
+
if (typeText.includes('NextPage') ||
|
|
254
|
+
typeText.includes('FC') ||
|
|
255
|
+
typeText.includes('React.FC')) {
|
|
256
|
+
return varDecl.getName();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Find any PascalCase exported function/const that looks like a component
|
|
261
|
+
for (const varDecl of varDeclarations) {
|
|
262
|
+
const name = varDecl.getName();
|
|
263
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(name)) {
|
|
264
|
+
const init = varDecl.getInitializer();
|
|
265
|
+
if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
|
|
266
|
+
// Check if it returns JSX
|
|
267
|
+
const text = init.getText();
|
|
268
|
+
if (text.includes('return') && (text.includes('<') || text.includes('jsx'))) {
|
|
269
|
+
return name;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
extractLayout(sourceFile) {
|
|
277
|
+
// Look for getLayout property
|
|
278
|
+
const getLayoutAssignment = sourceFile
|
|
279
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
280
|
+
.find((node) => node.getName() === 'getLayout');
|
|
281
|
+
if (getLayoutAssignment) {
|
|
282
|
+
const parent = getLayoutAssignment.getParent();
|
|
283
|
+
if (Node.isBinaryExpression(parent)) {
|
|
284
|
+
const right = parent.getRight();
|
|
285
|
+
// Extract layout component name from the function
|
|
286
|
+
const jsxElements = right.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
|
|
287
|
+
if (jsxElements.length > 0) {
|
|
288
|
+
return jsxElements[0].getTagNameNode().getText();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
extractAuthRequirement(sourceFile) {
|
|
295
|
+
const filePath = sourceFile.getFilePath();
|
|
296
|
+
const fileName = filePath.split('/').pop() || '';
|
|
297
|
+
// Pages that don't require authentication (exceptions)
|
|
298
|
+
const publicPages = [
|
|
299
|
+
'404.tsx',
|
|
300
|
+
'permission-denied.tsx',
|
|
301
|
+
'_app.tsx',
|
|
302
|
+
'_document.tsx',
|
|
303
|
+
'_error.tsx',
|
|
304
|
+
];
|
|
305
|
+
const isPublicPage = publicPages.some((p) => fileName === p);
|
|
306
|
+
// Default: ALL pages require authentication (because _app.tsx wraps everything with RequireAuthorization)
|
|
307
|
+
const result = {
|
|
308
|
+
required: !isPublicPage,
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
// Look for common auth/permission wrapper components (generic patterns)
|
|
312
|
+
const authPatterns = [
|
|
313
|
+
'RequiredCondition',
|
|
314
|
+
'ProtectedRoute',
|
|
315
|
+
'AuthGuard',
|
|
316
|
+
'PrivateRoute',
|
|
317
|
+
'WithAuth',
|
|
318
|
+
'RequireAuth',
|
|
319
|
+
'Authenticated',
|
|
320
|
+
'Authorized',
|
|
321
|
+
];
|
|
322
|
+
const authWrapper = sourceFile
|
|
323
|
+
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
|
|
324
|
+
.find((node) => {
|
|
325
|
+
const tagName = node.getTagNameNode().getText();
|
|
326
|
+
return authPatterns.some((pattern) => tagName.includes(pattern));
|
|
327
|
+
});
|
|
328
|
+
if (authWrapper) {
|
|
329
|
+
// Has additional permission requirements beyond basic auth
|
|
330
|
+
result.condition = 'Additional permissions required';
|
|
331
|
+
// Extract condition/roles - safely iterate attributes
|
|
332
|
+
const attributes = authWrapper.getAttributes();
|
|
333
|
+
for (const attr of attributes) {
|
|
334
|
+
if (attr.isKind(SyntaxKind.JsxAttribute)) {
|
|
335
|
+
try {
|
|
336
|
+
const name = attr.getNameNode().getText();
|
|
337
|
+
// Common attribute names for conditions/roles
|
|
338
|
+
if (['condition', 'roles', 'permissions', 'requiredRoles', 'allowedRoles'].includes(name)) {
|
|
339
|
+
const initializer = attr.getInitializer();
|
|
340
|
+
if (initializer) {
|
|
341
|
+
result.condition = initializer.getText();
|
|
342
|
+
// Extract roles from condition
|
|
343
|
+
const roles = this.extractRolesFromCondition(initializer.getText());
|
|
344
|
+
if (roles.length > 0) {
|
|
345
|
+
result.roles = roles;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Skip this attribute
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// Return default on error
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
extractRolesFromCondition(condition) {
|
|
363
|
+
const roles = [];
|
|
364
|
+
// Generic patterns for role extraction:
|
|
365
|
+
// - EnumName.RoleName (e.g., UserRole.Admin, MembershipRole.Owner)
|
|
366
|
+
// - 'role-name' or "role-name" string literals
|
|
367
|
+
// - ROLE_NAME constants
|
|
368
|
+
// Pattern 1: Enum-style roles (SomeEnum.RoleName)
|
|
369
|
+
const enumRoleRegex = /(\w+Role|\w+Permission)\.(\w+)/g;
|
|
370
|
+
let match;
|
|
371
|
+
while ((match = enumRoleRegex.exec(condition)) !== null) {
|
|
372
|
+
roles.push(match[2]);
|
|
373
|
+
}
|
|
374
|
+
// Pattern 2: String literals containing 'admin', 'user', 'owner', etc.
|
|
375
|
+
const stringRoleRegex = /['"]([a-zA-Z_-]+)['"]/g;
|
|
376
|
+
while ((match = stringRoleRegex.exec(condition)) !== null) {
|
|
377
|
+
const val = match[1];
|
|
378
|
+
// Only add if it looks like a role
|
|
379
|
+
if (/admin|user|owner|member|guest|manager|editor|viewer/i.test(val)) {
|
|
380
|
+
roles.push(val);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Pattern 3: UPPER_CASE constants
|
|
384
|
+
const constRoleRegex = /\b(ROLE_\w+|[A-Z]+_ROLE)\b/g;
|
|
385
|
+
while ((match = constRoleRegex.exec(condition)) !== null) {
|
|
386
|
+
roles.push(match[1]);
|
|
387
|
+
}
|
|
388
|
+
return [...new Set(roles)]; // Remove duplicates
|
|
389
|
+
}
|
|
390
|
+
extractPermissions(sourceFile) {
|
|
391
|
+
const permissions = [];
|
|
392
|
+
// Look for permission checks in the code
|
|
393
|
+
const permissionChecks = sourceFile
|
|
394
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
395
|
+
.filter((node) => {
|
|
396
|
+
const text = node.getText();
|
|
397
|
+
return text.includes('Permission') || text.includes('Role') || text.includes('isAdmin');
|
|
398
|
+
});
|
|
399
|
+
for (const check of permissionChecks) {
|
|
400
|
+
const text = check.getText();
|
|
401
|
+
if (!permissions.includes(text)) {
|
|
402
|
+
permissions.push(text);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return permissions;
|
|
406
|
+
}
|
|
407
|
+
extractDataFetching(sourceFile) {
|
|
408
|
+
const dataFetching = [];
|
|
409
|
+
// Build a map of imported GraphQL hooks (including aliases)
|
|
410
|
+
// e.g., import { useQuery as getQuery } from '@apollo/client'
|
|
411
|
+
const apolloHookAliases = new Map();
|
|
412
|
+
const apolloHooks = ['useQuery', 'useMutation', 'useLazyQuery', 'useSubscription'];
|
|
413
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
414
|
+
const moduleSpec = imp.getModuleSpecifierValue();
|
|
415
|
+
if (moduleSpec.includes('@apollo/client') || moduleSpec.includes('apollo')) {
|
|
416
|
+
for (const named of imp.getNamedImports()) {
|
|
417
|
+
const originalName = named.getName();
|
|
418
|
+
const alias = named.getAliasNode()?.getText() || originalName;
|
|
419
|
+
if (apolloHooks.includes(originalName)) {
|
|
420
|
+
apolloHookAliases.set(alias, originalName);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Check if this file uses Apollo Client
|
|
426
|
+
const hasApolloImport = apolloHookAliases.size > 0 ||
|
|
427
|
+
sourceFile.getImportDeclarations().some((imp) => {
|
|
428
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
429
|
+
return moduleSpecifier.includes('@apollo/client') || moduleSpecifier.includes('apollo');
|
|
430
|
+
});
|
|
431
|
+
// Find GraphQL hook calls - including aliases and custom hooks that wrap Apollo hooks
|
|
432
|
+
const graphqlHookCalls = sourceFile
|
|
433
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
434
|
+
.filter((call) => {
|
|
435
|
+
const expression = call.getExpression().getText();
|
|
436
|
+
// Direct Apollo hook or alias
|
|
437
|
+
if (apolloHookAliases.has(expression) || apolloHooks.includes(expression)) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
// Custom hooks pattern: use*Query, use*Mutation (e.g., useUserQuery, useFetchPosts)
|
|
441
|
+
// But exclude non-GraphQL hooks like useQueryParams, useQueryString
|
|
442
|
+
if (/^use[A-Z].*Query$/.test(expression) &&
|
|
443
|
+
!expression.includes('Params') &&
|
|
444
|
+
!expression.includes('String')) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (/^use[A-Z].*Mutation$/.test(expression)) {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
});
|
|
452
|
+
for (const call of graphqlHookCalls) {
|
|
453
|
+
const hookName = call.getExpression().getText();
|
|
454
|
+
// Determine the actual type (resolve alias to original name)
|
|
455
|
+
let resolvedType;
|
|
456
|
+
if (apolloHookAliases.has(hookName)) {
|
|
457
|
+
resolvedType = apolloHookAliases.get(hookName);
|
|
458
|
+
}
|
|
459
|
+
else if (hookName.includes('Mutation')) {
|
|
460
|
+
resolvedType = 'useMutation';
|
|
461
|
+
}
|
|
462
|
+
else if (hookName.includes('Lazy')) {
|
|
463
|
+
resolvedType = 'useLazyQuery';
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
resolvedType = 'useQuery';
|
|
467
|
+
}
|
|
468
|
+
const args = call.getArguments();
|
|
469
|
+
// Custom hooks might not have arguments (they encapsulate the query)
|
|
470
|
+
if (args.length === 0) {
|
|
471
|
+
// For custom hooks like useUserQuery(), extract name from hook name
|
|
472
|
+
if (/^use[A-Z]/.test(hookName)) {
|
|
473
|
+
const operationName = hookName.replace(/^use/, '').replace(/Query$|Mutation$/, '');
|
|
474
|
+
dataFetching.push({ type: resolvedType, operationName, variables: [] });
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const firstArg = args[0];
|
|
479
|
+
const firstArgText = firstArg.getText();
|
|
480
|
+
// Skip if first argument is:
|
|
481
|
+
// - An array literal: ['key', ...]
|
|
482
|
+
// - An object literal: { queryKey: ... }
|
|
483
|
+
// - A string literal: 'queryKey'
|
|
484
|
+
// These are React Query/TanStack Query patterns, not Apollo
|
|
485
|
+
if (firstArgText.startsWith('[') ||
|
|
486
|
+
firstArgText.startsWith('{') ||
|
|
487
|
+
firstArgText.startsWith("'") ||
|
|
488
|
+
firstArgText.startsWith('"') ||
|
|
489
|
+
firstArgText.startsWith('`')) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// Apollo Client pattern: first arg should be a Document identifier
|
|
493
|
+
// Valid patterns: GetUserDocument, GET_USER_QUERY, gql`...`
|
|
494
|
+
const isApolloPattern = hasApolloImport ||
|
|
495
|
+
firstArgText.endsWith('Document') ||
|
|
496
|
+
firstArgText.endsWith('Query') ||
|
|
497
|
+
firstArgText.endsWith('Mutation') ||
|
|
498
|
+
firstArgText.includes('gql') ||
|
|
499
|
+
/^[A-Z_]+$/.test(firstArgText); // SCREAMING_CASE constant
|
|
500
|
+
if (!isApolloPattern) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const operationName = firstArgText.replace(/Document$/, '').replace(/Query$|Mutation$/, '');
|
|
504
|
+
const variables = [];
|
|
505
|
+
if (args.length > 1) {
|
|
506
|
+
const optionsArg = args[1];
|
|
507
|
+
const variablesProperty = optionsArg
|
|
508
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
509
|
+
.find((prop) => {
|
|
510
|
+
try {
|
|
511
|
+
return prop.getName() === 'variables';
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
if (variablesProperty) {
|
|
518
|
+
const initializer = variablesProperty.getInitializer();
|
|
519
|
+
if (initializer) {
|
|
520
|
+
// Extract variable names
|
|
521
|
+
const props = initializer.getDescendantsOfKind(SyntaxKind.PropertyAssignment);
|
|
522
|
+
for (const prop of props) {
|
|
523
|
+
try {
|
|
524
|
+
variables.push(prop.getName());
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// Skip
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
dataFetching.push({ type: resolvedType, operationName, variables });
|
|
534
|
+
}
|
|
535
|
+
// Find getServerSideProps and extract GraphQL queries
|
|
536
|
+
const getServerSidePropsVar = sourceFile.getVariableDeclaration('getServerSideProps');
|
|
537
|
+
const getServerSidePropsFunc = sourceFile.getFunction('getServerSideProps');
|
|
538
|
+
const ssrNode = getServerSidePropsVar || getServerSidePropsFunc;
|
|
539
|
+
if (ssrNode) {
|
|
540
|
+
// Look for imported Document patterns (e.g., GetUserOnboardingUserDocument)
|
|
541
|
+
const imports = sourceFile.getImportDeclarations();
|
|
542
|
+
for (const imp of imports) {
|
|
543
|
+
const namedImports = imp.getNamedImports();
|
|
544
|
+
for (const named of namedImports) {
|
|
545
|
+
const name = named.getName();
|
|
546
|
+
if (name.endsWith('Document')) {
|
|
547
|
+
// Check if this document is used in the file
|
|
548
|
+
const usages = sourceFile
|
|
549
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
550
|
+
.filter((id) => id.getText() === name);
|
|
551
|
+
if (usages.length > 0) {
|
|
552
|
+
const operationName = name.replace(/Document$/, '');
|
|
553
|
+
dataFetching.push({
|
|
554
|
+
type: 'getServerSideProps',
|
|
555
|
+
operationName: `→ ${operationName}`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Also look for inline query patterns: graphqlClient.query({ query: ... })
|
|
562
|
+
const text = ssrNode.getText();
|
|
563
|
+
const queryMatches = text.match(/query:\s*(\w+)/g);
|
|
564
|
+
if (queryMatches) {
|
|
565
|
+
for (const match of queryMatches) {
|
|
566
|
+
const docName = match.replace(/query:\s*/, '');
|
|
567
|
+
if (!dataFetching.some((d) => d.operationName?.includes(docName.replace(/Document$/, '')))) {
|
|
568
|
+
dataFetching.push({
|
|
569
|
+
type: 'getServerSideProps',
|
|
570
|
+
operationName: `→ ${docName.replace(/Document$/, '')}`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Find getStaticProps
|
|
577
|
+
const getStaticPropsVar = sourceFile.getVariableDeclaration('getStaticProps');
|
|
578
|
+
const getStaticPropsFunc = sourceFile.getFunction('getStaticProps');
|
|
579
|
+
if (getStaticPropsVar || getStaticPropsFunc) {
|
|
580
|
+
dataFetching.push({
|
|
581
|
+
type: 'getStaticProps',
|
|
582
|
+
operationName: 'getStaticProps',
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
// Track component imports from relative paths and analyze their GraphQL usage
|
|
586
|
+
const imports = sourceFile.getImportDeclarations();
|
|
587
|
+
const sourceFilePath = sourceFile.getFilePath();
|
|
588
|
+
const sourceFileDir = path.dirname(sourceFilePath);
|
|
589
|
+
for (const imp of imports) {
|
|
590
|
+
const moduleSpec = imp.getModuleSpecifierValue();
|
|
591
|
+
// Skip external packages (node_modules) - only track relative imports
|
|
592
|
+
const isRelativeImport = moduleSpec.startsWith('.') || moduleSpec.startsWith('/');
|
|
593
|
+
const isInternalAlias = !moduleSpec.includes('node_modules') &&
|
|
594
|
+
!moduleSpec.startsWith('@types/') &&
|
|
595
|
+
moduleSpec.startsWith('@') === false; // Skip scoped packages
|
|
596
|
+
if (isRelativeImport || isInternalAlias) {
|
|
597
|
+
// Skip codegen/generated folders - these contain GraphQL types, not components
|
|
598
|
+
if (moduleSpec.includes('__generated__') || moduleSpec.includes('/generated/')) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
// Collect component names from this import
|
|
602
|
+
const componentNames = [];
|
|
603
|
+
// Check named imports for component-like names (PascalCase)
|
|
604
|
+
const namedImports = imp.getNamedImports();
|
|
605
|
+
for (const named of namedImports) {
|
|
606
|
+
const name = named.getName();
|
|
607
|
+
if (this.isComponentName(name)) {
|
|
608
|
+
componentNames.push(name);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Check default import
|
|
612
|
+
const defaultImport = imp.getDefaultImport();
|
|
613
|
+
if (defaultImport) {
|
|
614
|
+
const name = defaultImport.getText();
|
|
615
|
+
if (this.isComponentName(name)) {
|
|
616
|
+
componentNames.push(name);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// For each component, try to analyze the imported file for GraphQL
|
|
620
|
+
for (const componentName of componentNames) {
|
|
621
|
+
// Try to resolve and analyze the imported component file
|
|
622
|
+
const importedQueries = this.analyzeImportedComponent(sourceFileDir, moduleSpec, componentName);
|
|
623
|
+
if (importedQueries.length > 0) {
|
|
624
|
+
// Add the queries found in the component with a reference marker
|
|
625
|
+
for (const query of importedQueries) {
|
|
626
|
+
dataFetching.push({
|
|
627
|
+
type: query.type,
|
|
628
|
+
operationName: query.operationName.startsWith('→')
|
|
629
|
+
? `→ ${query.operationName} (${componentName})`
|
|
630
|
+
: `→ ${query.operationName} (${componentName})`,
|
|
631
|
+
variables: query.variables,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
// No queries found, just mark as component reference
|
|
637
|
+
dataFetching.push({
|
|
638
|
+
type: 'component',
|
|
639
|
+
operationName: componentName,
|
|
640
|
+
variables: [],
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return dataFetching;
|
|
647
|
+
}
|
|
648
|
+
// Symbol tracking cache to avoid re-analyzing the same files
|
|
649
|
+
symbolTraceCache = new Map();
|
|
650
|
+
/**
|
|
651
|
+
* Analyze an imported component file for GraphQL queries with full symbol tracing
|
|
652
|
+
* 임포트된 컴포넌트를 재귀적으로 완전 추적
|
|
653
|
+
*/
|
|
654
|
+
analyzeImportedComponent(sourceFileDir, moduleSpec, componentName, visited = new Set(), depth = 0) {
|
|
655
|
+
const MAX_DEPTH = 10; // 무한 루프 방지
|
|
656
|
+
if (depth > MAX_DEPTH)
|
|
657
|
+
return [];
|
|
658
|
+
const queries = [];
|
|
659
|
+
try {
|
|
660
|
+
// Resolve the import path
|
|
661
|
+
const resolvedPath = path.resolve(sourceFileDir, moduleSpec);
|
|
662
|
+
// Create a unique key for cycle detection
|
|
663
|
+
const cacheKey = `${resolvedPath}:${componentName}`;
|
|
664
|
+
if (visited.has(cacheKey)) {
|
|
665
|
+
return []; // 순환 참조 방지
|
|
666
|
+
}
|
|
667
|
+
visited.add(cacheKey);
|
|
668
|
+
// Check cache
|
|
669
|
+
const cachedResult = this.symbolTraceCache.get(cacheKey);
|
|
670
|
+
if (cachedResult !== undefined) {
|
|
671
|
+
return cachedResult;
|
|
672
|
+
}
|
|
673
|
+
// Try different file extensions and index files
|
|
674
|
+
const possiblePaths = [
|
|
675
|
+
`${resolvedPath}.tsx`,
|
|
676
|
+
`${resolvedPath}.ts`,
|
|
677
|
+
`${resolvedPath}/index.tsx`,
|
|
678
|
+
`${resolvedPath}/index.ts`,
|
|
679
|
+
`${resolvedPath}/${componentName}.tsx`,
|
|
680
|
+
`${resolvedPath}/${componentName}.ts`,
|
|
681
|
+
];
|
|
682
|
+
let componentFile;
|
|
683
|
+
let componentFilePath;
|
|
684
|
+
for (const tryPath of possiblePaths) {
|
|
685
|
+
try {
|
|
686
|
+
componentFile = this.project.addSourceFileAtPath(tryPath);
|
|
687
|
+
if (componentFile) {
|
|
688
|
+
componentFilePath = tryPath;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// File doesn't exist, try next
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!componentFile || !componentFilePath)
|
|
697
|
+
return queries;
|
|
698
|
+
// If this is an index file, follow re-exports to find the actual component file
|
|
699
|
+
if (componentFilePath.endsWith('index.tsx') || componentFilePath.endsWith('index.ts')) {
|
|
700
|
+
const actualFile = this.followReExport(componentFile, componentName, path.dirname(componentFilePath));
|
|
701
|
+
if (actualFile) {
|
|
702
|
+
componentFile = actualFile;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Check for Apollo Client imports (also check for gql imports which indicate GraphQL usage)
|
|
706
|
+
// Also check for graphql.macro, graphql-tag which are commonly used
|
|
707
|
+
const hasGraphQLImport = componentFile.getImportDeclarations().some((imp) => {
|
|
708
|
+
const spec = imp.getModuleSpecifierValue();
|
|
709
|
+
return (spec.includes('@apollo/client') ||
|
|
710
|
+
spec.includes('apollo') ||
|
|
711
|
+
spec.includes('gql') ||
|
|
712
|
+
spec.includes('graphql') || // graphql.macro, graphql-tag
|
|
713
|
+
spec.includes('__generated__'));
|
|
714
|
+
});
|
|
715
|
+
// Get all relative imports for full symbol tracing
|
|
716
|
+
const relativeImports = componentFile.getImportDeclarations().filter((imp) => {
|
|
717
|
+
const spec = imp.getModuleSpecifierValue();
|
|
718
|
+
return spec.startsWith('./') || spec.startsWith('../');
|
|
719
|
+
});
|
|
720
|
+
// 1. Analyze custom hooks (use* pattern)
|
|
721
|
+
for (const imp of relativeImports) {
|
|
722
|
+
const hookSpec = imp.getModuleSpecifierValue();
|
|
723
|
+
const hookNames = imp
|
|
724
|
+
.getNamedImports()
|
|
725
|
+
.map((n) => n.getName())
|
|
726
|
+
.filter((n) => /^use[A-Z]/.test(n));
|
|
727
|
+
for (const hookName of hookNames) {
|
|
728
|
+
const hookQueries = this.analyzeCustomHook(path.dirname(componentFile.getFilePath()), hookSpec, hookName, visited, depth + 1);
|
|
729
|
+
queries.push(...hookQueries);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// 2. Deep trace all imported components (not just Container/Page)
|
|
733
|
+
for (const imp of relativeImports) {
|
|
734
|
+
const nestedSpec = imp.getModuleSpecifierValue();
|
|
735
|
+
const namedImports = imp.getNamedImports().map((n) => n.getName());
|
|
736
|
+
const defaultImport = imp.getDefaultImport()?.getText();
|
|
737
|
+
// Analyze ALL PascalCase imports (components)
|
|
738
|
+
const componentImports = namedImports.filter((n) => /^[A-Z]/.test(n) && this.isComponentName(n));
|
|
739
|
+
for (const nestedComponentName of componentImports) {
|
|
740
|
+
const nestedQueries = this.analyzeImportedComponent(path.dirname(componentFile.getFilePath()), nestedSpec, nestedComponentName, visited, depth + 1);
|
|
741
|
+
queries.push(...nestedQueries);
|
|
742
|
+
}
|
|
743
|
+
// Also check default imports
|
|
744
|
+
if (defaultImport && /^[A-Z]/.test(defaultImport) && this.isComponentName(defaultImport)) {
|
|
745
|
+
const nestedQueries = this.analyzeImportedComponent(path.dirname(componentFile.getFilePath()), nestedSpec, defaultImport, visited, depth + 1);
|
|
746
|
+
queries.push(...nestedQueries);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// 3. If has GraphQL imports, look for direct hook calls
|
|
750
|
+
if (hasGraphQLImport) {
|
|
751
|
+
// Find GraphQL hook calls in the component
|
|
752
|
+
const hookCalls = componentFile
|
|
753
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
754
|
+
.filter((call) => {
|
|
755
|
+
const expression = call.getExpression().getText();
|
|
756
|
+
return ['useQuery', 'useMutation', 'useLazyQuery', 'useSubscription'].includes(expression);
|
|
757
|
+
});
|
|
758
|
+
for (const call of hookCalls) {
|
|
759
|
+
const hookName = call.getExpression().getText();
|
|
760
|
+
const args = call.getArguments();
|
|
761
|
+
if (args.length === 0)
|
|
762
|
+
continue;
|
|
763
|
+
const firstArg = args[0];
|
|
764
|
+
const firstArgText = firstArg.getText();
|
|
765
|
+
// Extract operation name from the first argument
|
|
766
|
+
let operationName = firstArgText;
|
|
767
|
+
let operationType = null;
|
|
768
|
+
// Try codegen mapping first
|
|
769
|
+
const codegenInfo = this.resolveDocumentName(firstArgText);
|
|
770
|
+
if (codegenInfo) {
|
|
771
|
+
operationName = codegenInfo.operationName;
|
|
772
|
+
operationType = codegenInfo.operationType;
|
|
773
|
+
}
|
|
774
|
+
// Handle codegen Document pattern: SomeQueryDocument → SomeQuery
|
|
775
|
+
else if (firstArgText.endsWith('Document')) {
|
|
776
|
+
operationName = firstArgText.replace(/Document$/, '');
|
|
777
|
+
}
|
|
778
|
+
// If it's a variable reference, try to find the actual query name
|
|
779
|
+
else if (/^[A-Za-z]/.test(firstArgText)) {
|
|
780
|
+
// Use the helper function to find operation name from variable
|
|
781
|
+
const extractedName = this.findOperationNameFromVariable(componentFile, firstArgText);
|
|
782
|
+
if (extractedName) {
|
|
783
|
+
operationName = extractedName;
|
|
784
|
+
}
|
|
785
|
+
// If still using the original variable name and it's a common pattern,
|
|
786
|
+
// try to extract operation name from it (e.g., ProfilePreviewQuery → ProfilePreview)
|
|
787
|
+
if (operationName === firstArgText &&
|
|
788
|
+
firstArgText !== 'Query' &&
|
|
789
|
+
firstArgText !== 'Mutation') {
|
|
790
|
+
const nameMatch = firstArgText.match(/^(.+?)(Query|Mutation|Subscription)$/);
|
|
791
|
+
if (nameMatch) {
|
|
792
|
+
operationName = nameMatch[1];
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// Clean up operation name - but don't remove if it would become empty
|
|
797
|
+
// Also don't clean if it's just "Query" or "Mutation" (common variable names)
|
|
798
|
+
if (operationName !== 'Query' && operationName !== 'Mutation') {
|
|
799
|
+
const cleanedName = operationName
|
|
800
|
+
.replace(/Document$/, '')
|
|
801
|
+
.replace(/Query$|Mutation$/, '');
|
|
802
|
+
operationName = cleanedName || operationName;
|
|
803
|
+
}
|
|
804
|
+
// If operationName is still a generic name like "Query", try to extract from variable name pattern
|
|
805
|
+
// e.g., from useQuery(Query, ...) where Query = gql`query ActualName { ... }`
|
|
806
|
+
if (operationName === 'Query' || operationName === 'Mutation' || operationName === '') {
|
|
807
|
+
// The variable lookup already happened above but may have failed
|
|
808
|
+
// In this case, keep the original but mark it needs the file context
|
|
809
|
+
if (operationName === '') {
|
|
810
|
+
operationName = firstArgText || 'Unknown';
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const type = operationType
|
|
814
|
+
? operationType === 'mutation'
|
|
815
|
+
? 'useMutation'
|
|
816
|
+
: operationType === 'subscription'
|
|
817
|
+
? 'useSubscription'
|
|
818
|
+
: hookName.includes('Lazy')
|
|
819
|
+
? 'useLazyQuery'
|
|
820
|
+
: 'useQuery'
|
|
821
|
+
: hookName.includes('Mutation')
|
|
822
|
+
? 'useMutation'
|
|
823
|
+
: hookName.includes('Lazy')
|
|
824
|
+
? 'useLazyQuery'
|
|
825
|
+
: 'useQuery';
|
|
826
|
+
queries.push({
|
|
827
|
+
type: type,
|
|
828
|
+
operationName,
|
|
829
|
+
variables: [],
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// Cache the results
|
|
834
|
+
this.symbolTraceCache.set(cacheKey, queries);
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// Failed to analyze imported component, skip
|
|
838
|
+
}
|
|
839
|
+
return queries;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Analyze a custom hook file for GraphQL queries with recursive tracing
|
|
843
|
+
*/
|
|
844
|
+
analyzeCustomHook(sourceFileDir, moduleSpec, hookName, visited = new Set(), depth = 0) {
|
|
845
|
+
const MAX_DEPTH = 10;
|
|
846
|
+
if (depth > MAX_DEPTH)
|
|
847
|
+
return [];
|
|
848
|
+
const queries = [];
|
|
849
|
+
try {
|
|
850
|
+
// Resolve the import path
|
|
851
|
+
const resolvedPath = path.resolve(sourceFileDir, moduleSpec);
|
|
852
|
+
// Create a unique key for cycle detection
|
|
853
|
+
const cacheKey = `hook:${resolvedPath}:${hookName}`;
|
|
854
|
+
if (visited.has(cacheKey))
|
|
855
|
+
return [];
|
|
856
|
+
visited.add(cacheKey);
|
|
857
|
+
// Check cache
|
|
858
|
+
const cachedHookResult = this.symbolTraceCache.get(cacheKey);
|
|
859
|
+
if (cachedHookResult !== undefined) {
|
|
860
|
+
return cachedHookResult;
|
|
861
|
+
}
|
|
862
|
+
const possiblePaths = [
|
|
863
|
+
`${resolvedPath}.tsx`,
|
|
864
|
+
`${resolvedPath}.ts`,
|
|
865
|
+
`${resolvedPath}/${hookName}.tsx`,
|
|
866
|
+
`${resolvedPath}/${hookName}.ts`,
|
|
867
|
+
`${resolvedPath}/index.tsx`,
|
|
868
|
+
`${resolvedPath}/index.ts`,
|
|
869
|
+
];
|
|
870
|
+
let hookFile;
|
|
871
|
+
for (const tryPath of possiblePaths) {
|
|
872
|
+
try {
|
|
873
|
+
hookFile = this.project.addSourceFileAtPath(tryPath);
|
|
874
|
+
if (hookFile)
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// File doesn't exist, try next
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (!hookFile)
|
|
882
|
+
return queries;
|
|
883
|
+
// Check for Apollo Client imports
|
|
884
|
+
const hasGraphQLImport = hookFile.getImportDeclarations().some((imp) => {
|
|
885
|
+
const spec = imp.getModuleSpecifierValue();
|
|
886
|
+
return (spec.includes('@apollo/client') ||
|
|
887
|
+
spec.includes('apollo') ||
|
|
888
|
+
spec.includes('graphql') ||
|
|
889
|
+
spec.includes('__generated__'));
|
|
890
|
+
});
|
|
891
|
+
// Also trace nested custom hooks even if no direct GraphQL import
|
|
892
|
+
const relativeImports = hookFile.getImportDeclarations().filter((imp) => {
|
|
893
|
+
const spec = imp.getModuleSpecifierValue();
|
|
894
|
+
return spec.startsWith('./') || spec.startsWith('../');
|
|
895
|
+
});
|
|
896
|
+
for (const imp of relativeImports) {
|
|
897
|
+
const nestedSpec = imp.getModuleSpecifierValue();
|
|
898
|
+
const nestedHookNames = imp
|
|
899
|
+
.getNamedImports()
|
|
900
|
+
.map((n) => n.getName())
|
|
901
|
+
.filter((n) => /^use[A-Z]/.test(n));
|
|
902
|
+
for (const nestedHookName of nestedHookNames) {
|
|
903
|
+
const nestedQueries = this.analyzeCustomHook(path.dirname(hookFile.getFilePath()), nestedSpec, nestedHookName, visited, depth + 1);
|
|
904
|
+
queries.push(...nestedQueries);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (!hasGraphQLImport && queries.length === 0) {
|
|
908
|
+
return queries;
|
|
909
|
+
}
|
|
910
|
+
// Find useQuery/useMutation calls in the hook
|
|
911
|
+
const hookCalls = hookFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
|
|
912
|
+
const expression = call.getExpression().getText();
|
|
913
|
+
return ['useQuery', 'useMutation', 'useLazyQuery', 'useSubscription'].includes(expression);
|
|
914
|
+
});
|
|
915
|
+
for (const call of hookCalls) {
|
|
916
|
+
const callHookName = call.getExpression().getText();
|
|
917
|
+
const args = call.getArguments();
|
|
918
|
+
if (args.length === 0)
|
|
919
|
+
continue;
|
|
920
|
+
const firstArgText = args[0].getText();
|
|
921
|
+
let operationName = firstArgText;
|
|
922
|
+
let operationType = null;
|
|
923
|
+
// Try codegen mapping first
|
|
924
|
+
const codegenInfo = this.resolveDocumentName(firstArgText);
|
|
925
|
+
if (codegenInfo) {
|
|
926
|
+
operationName = codegenInfo.operationName;
|
|
927
|
+
operationType = codegenInfo.operationType;
|
|
928
|
+
}
|
|
929
|
+
// Handle codegen Document pattern: SomeQueryDocument → SomeQuery
|
|
930
|
+
else if (firstArgText.endsWith('Document')) {
|
|
931
|
+
operationName = firstArgText.replace(/Document$/, '');
|
|
932
|
+
}
|
|
933
|
+
// If it's a variable reference, try to find the actual query name
|
|
934
|
+
else if (/^[A-Za-z]/.test(firstArgText)) {
|
|
935
|
+
// Use the helper function to find operation name from variable
|
|
936
|
+
const extractedName = this.findOperationNameFromVariable(hookFile, firstArgText);
|
|
937
|
+
if (extractedName) {
|
|
938
|
+
operationName = extractedName;
|
|
939
|
+
}
|
|
940
|
+
// If still using the original variable name and it's a common pattern,
|
|
941
|
+
// try to extract operation name from it (e.g., ProfilePreviewQuery → ProfilePreview)
|
|
942
|
+
if (operationName === firstArgText &&
|
|
943
|
+
firstArgText !== 'Query' &&
|
|
944
|
+
firstArgText !== 'Mutation') {
|
|
945
|
+
const nameMatch = firstArgText.match(/^(.+?)(Query|Mutation|Subscription)$/);
|
|
946
|
+
if (nameMatch) {
|
|
947
|
+
operationName = nameMatch[1];
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// Clean up operation name - but don't remove if it would become empty
|
|
952
|
+
// Also don't clean if it's just "Query" or "Mutation" (common variable names)
|
|
953
|
+
if (operationName !== 'Query' && operationName !== 'Mutation') {
|
|
954
|
+
const cleanedName = operationName
|
|
955
|
+
.replace(/Document$/, '')
|
|
956
|
+
.replace(/Query$|Mutation$/, '');
|
|
957
|
+
operationName = cleanedName || operationName;
|
|
958
|
+
}
|
|
959
|
+
// Prevent empty operation names
|
|
960
|
+
if (operationName === '') {
|
|
961
|
+
operationName = firstArgText || 'Unknown';
|
|
962
|
+
}
|
|
963
|
+
const type = operationType
|
|
964
|
+
? operationType === 'mutation'
|
|
965
|
+
? 'useMutation'
|
|
966
|
+
: operationType === 'subscription'
|
|
967
|
+
? 'useSubscription'
|
|
968
|
+
: callHookName.includes('Lazy')
|
|
969
|
+
? 'useLazyQuery'
|
|
970
|
+
: 'useQuery'
|
|
971
|
+
: callHookName.includes('Mutation')
|
|
972
|
+
? 'useMutation'
|
|
973
|
+
: callHookName.includes('Lazy')
|
|
974
|
+
? 'useLazyQuery'
|
|
975
|
+
: 'useQuery';
|
|
976
|
+
queries.push({
|
|
977
|
+
type: type,
|
|
978
|
+
operationName: `→ ${operationName} (via ${hookName})`,
|
|
979
|
+
variables: [],
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
// Cache the results
|
|
983
|
+
this.symbolTraceCache.set(cacheKey, queries);
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
// Failed to analyze hook
|
|
987
|
+
}
|
|
988
|
+
return queries;
|
|
989
|
+
}
|
|
990
|
+
// Global context providers and their GraphQL queries
|
|
991
|
+
globalContextQueries = [];
|
|
992
|
+
/**
|
|
993
|
+
* Find page files from multiple possible locations
|
|
994
|
+
* Next.js, React, Rails+React 구조 모두 지원
|
|
995
|
+
*/
|
|
996
|
+
async findPageFiles() {
|
|
997
|
+
const pagesDir = this.getSetting('pagesDir', 'src/pages');
|
|
998
|
+
const allFiles = [];
|
|
999
|
+
// 1. Check Next.js standard directories (deduplicate)
|
|
1000
|
+
const nextjsDirsSet = new Set([pagesDir, 'pages', 'src/pages', 'app', 'src/app']);
|
|
1001
|
+
const nextjsDirs = [...nextjsDirsSet];
|
|
1002
|
+
for (const dir of nextjsDirs) {
|
|
1003
|
+
// Skip Rails 'app' directory (contains controllers, models, views - not React pages)
|
|
1004
|
+
if (dir === 'app' || dir === 'src/app') {
|
|
1005
|
+
const railsIndicators = ['controllers', 'models', 'views', 'helpers'];
|
|
1006
|
+
const dirPath = this.resolvePath(dir);
|
|
1007
|
+
const hasRailsStructure = railsIndicators.some((subdir) => {
|
|
1008
|
+
try {
|
|
1009
|
+
return fs.existsSync(path.join(dirPath, subdir));
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
if (hasRailsStructure) {
|
|
1016
|
+
continue; // Skip Rails app directory
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const dirPath = this.resolvePath(dir);
|
|
1020
|
+
try {
|
|
1021
|
+
const files = await fg(['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], {
|
|
1022
|
+
cwd: dirPath,
|
|
1023
|
+
ignore: [
|
|
1024
|
+
'_app.tsx',
|
|
1025
|
+
'_app.ts',
|
|
1026
|
+
'_app.jsx',
|
|
1027
|
+
'_app.js',
|
|
1028
|
+
'_document.tsx',
|
|
1029
|
+
'_document.ts',
|
|
1030
|
+
'_document.jsx',
|
|
1031
|
+
'_document.js',
|
|
1032
|
+
'_error.tsx',
|
|
1033
|
+
'_error.ts',
|
|
1034
|
+
'_error.jsx',
|
|
1035
|
+
'_error.js',
|
|
1036
|
+
'api/**',
|
|
1037
|
+
'**/*.test.*',
|
|
1038
|
+
'**/*.spec.*',
|
|
1039
|
+
'**/node_modules/**',
|
|
1040
|
+
'**/components/pages/**', // Reusable page components, not routes
|
|
1041
|
+
],
|
|
1042
|
+
absolute: true,
|
|
1043
|
+
});
|
|
1044
|
+
allFiles.push(...files);
|
|
1045
|
+
if (files.length > 0) {
|
|
1046
|
+
this.log(`Found ${files.length} pages in ${dir}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// Directory doesn't exist
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// 2. Check Rails + React structures (exclude components/pages - those are reusable components, not routes)
|
|
1054
|
+
const railsReactDirs = ['frontend/src/**/pages', 'app/javascript/**/pages'];
|
|
1055
|
+
for (const pattern of railsReactDirs) {
|
|
1056
|
+
try {
|
|
1057
|
+
const files = await fg([
|
|
1058
|
+
`${pattern}/**/*.tsx`,
|
|
1059
|
+
`${pattern}/**/*.ts`,
|
|
1060
|
+
`${pattern}/**/*.jsx`,
|
|
1061
|
+
`${pattern}/**/*.js`,
|
|
1062
|
+
], {
|
|
1063
|
+
cwd: this.basePath,
|
|
1064
|
+
ignore: [
|
|
1065
|
+
'**/*.test.*',
|
|
1066
|
+
'**/*.spec.*',
|
|
1067
|
+
'**/node_modules/**',
|
|
1068
|
+
'**/vendor/**',
|
|
1069
|
+
'**/components/pages/**', // Exclude reusable page components (not actual routes)
|
|
1070
|
+
'**/stories/**', // Exclude storybook files
|
|
1071
|
+
],
|
|
1072
|
+
absolute: true,
|
|
1073
|
+
});
|
|
1074
|
+
allFiles.push(...files);
|
|
1075
|
+
if (files.length > 0) {
|
|
1076
|
+
this.log(`Found ${files.length} React pages in ${pattern}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Pattern doesn't match
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// 3. Check for entry point files (Rails with Webpacker/Shakapacker)
|
|
1084
|
+
const entryPatterns = [
|
|
1085
|
+
'frontend/src/**/index.tsx',
|
|
1086
|
+
'frontend/src/**/App.tsx',
|
|
1087
|
+
'app/javascript/packs/*.tsx',
|
|
1088
|
+
'app/javascript/packs/*.jsx',
|
|
1089
|
+
];
|
|
1090
|
+
for (const pattern of entryPatterns) {
|
|
1091
|
+
try {
|
|
1092
|
+
const files = await fg([pattern], {
|
|
1093
|
+
cwd: this.basePath,
|
|
1094
|
+
ignore: ['**/node_modules/**', '**/vendor/**'],
|
|
1095
|
+
absolute: true,
|
|
1096
|
+
});
|
|
1097
|
+
// Don't add duplicates
|
|
1098
|
+
for (const file of files) {
|
|
1099
|
+
if (!allFiles.includes(file)) {
|
|
1100
|
+
allFiles.push(file);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
// Pattern doesn't match
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// Remove duplicates
|
|
1109
|
+
return [...new Set(allFiles)];
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Analyze _app.tsx for global providers that use GraphQL
|
|
1113
|
+
* _app.tsx에서 전역 Context Provider의 GraphQL 분석
|
|
1114
|
+
*/
|
|
1115
|
+
async analyzeAppFile() {
|
|
1116
|
+
const pagesDir = this.getSetting('pagesDir', 'src/pages');
|
|
1117
|
+
const possiblePaths = [
|
|
1118
|
+
this.resolvePath(`${pagesDir}/_app.tsx`),
|
|
1119
|
+
this.resolvePath(`${pagesDir}/_app.ts`),
|
|
1120
|
+
];
|
|
1121
|
+
for (const appPath of possiblePaths) {
|
|
1122
|
+
try {
|
|
1123
|
+
const appFile = this.project.addSourceFileAtPath(appPath);
|
|
1124
|
+
if (!appFile)
|
|
1125
|
+
continue;
|
|
1126
|
+
// Find Provider components used in _app
|
|
1127
|
+
const jsxElements = appFile.getDescendantsOfKind(SyntaxKind.JsxElement);
|
|
1128
|
+
const jsxSelfClosing = appFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
|
|
1129
|
+
const providerNames = new Set();
|
|
1130
|
+
for (const el of [...jsxElements, ...jsxSelfClosing]) {
|
|
1131
|
+
const tagName = el.getFirstDescendantByKind(SyntaxKind.Identifier)?.getText();
|
|
1132
|
+
if (tagName && (tagName.includes('Provider') || tagName.includes('Context'))) {
|
|
1133
|
+
providerNames.add(tagName);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Find imports for these providers
|
|
1137
|
+
for (const imp of appFile.getImportDeclarations()) {
|
|
1138
|
+
const spec = imp.getModuleSpecifierValue();
|
|
1139
|
+
if (!spec.startsWith('./') && !spec.startsWith('../'))
|
|
1140
|
+
continue;
|
|
1141
|
+
const namedImports = imp.getNamedImports().map((n) => n.getName());
|
|
1142
|
+
const defaultImport = imp.getDefaultImport()?.getText();
|
|
1143
|
+
for (const providerName of providerNames) {
|
|
1144
|
+
if (namedImports.includes(providerName) || defaultImport === providerName) {
|
|
1145
|
+
// Analyze the provider file for GraphQL usage
|
|
1146
|
+
const providerQueries = this.analyzeImportedComponent(path.dirname(appPath), spec, providerName, new Set(), 0);
|
|
1147
|
+
for (const q of providerQueries) {
|
|
1148
|
+
// Mark as global context
|
|
1149
|
+
this.globalContextQueries.push({
|
|
1150
|
+
...q,
|
|
1151
|
+
operationName: `[Global] ${q.operationName}`,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (this.globalContextQueries.length > 0) {
|
|
1158
|
+
this.log(`Found ${this.globalContextQueries.length} global context queries from _app`);
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
// File doesn't exist, continue
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Load codegen mapping from __generated__ folders (optional)
|
|
1169
|
+
* 코드젠 폴더가 있으면 Document → Operation name 매핑 로드
|
|
1170
|
+
*/
|
|
1171
|
+
async loadCodegenMapping() {
|
|
1172
|
+
const possibleDirs = [
|
|
1173
|
+
'__generated__',
|
|
1174
|
+
'src/__generated__',
|
|
1175
|
+
'src/__generated__/gql-graphql-gateway',
|
|
1176
|
+
'generated',
|
|
1177
|
+
'src/generated',
|
|
1178
|
+
];
|
|
1179
|
+
for (const dir of possibleDirs) {
|
|
1180
|
+
const generatedPath = this.resolvePath(dir);
|
|
1181
|
+
try {
|
|
1182
|
+
const files = await fg(['**/*.ts', '**/*.tsx'], {
|
|
1183
|
+
cwd: generatedPath,
|
|
1184
|
+
absolute: true,
|
|
1185
|
+
onlyFiles: true,
|
|
1186
|
+
});
|
|
1187
|
+
for (const file of files) {
|
|
1188
|
+
try {
|
|
1189
|
+
const sourceFile = this.project.addSourceFileAtPath(file);
|
|
1190
|
+
// Look for DocumentNode exports with operation names
|
|
1191
|
+
// Pattern 1: export const SomeQueryDocument = gql`query SomeName { ... }`
|
|
1192
|
+
// Pattern 2: export type SomeQueryDocument = DocumentNode
|
|
1193
|
+
const varDecls = sourceFile.getVariableDeclarations();
|
|
1194
|
+
for (const varDecl of varDecls) {
|
|
1195
|
+
const name = varDecl.getName();
|
|
1196
|
+
if (name.endsWith('Document')) {
|
|
1197
|
+
const initializer = varDecl.getInitializer()?.getText() ?? '';
|
|
1198
|
+
// Extract operation name and type from gql template
|
|
1199
|
+
const operationMatch = initializer.match(/(?:query|mutation|subscription)\s+(\w+)/);
|
|
1200
|
+
const typeMatch = initializer.match(/(query|mutation|subscription)\s+/);
|
|
1201
|
+
if (operationMatch) {
|
|
1202
|
+
this.codegenMap.set(name, {
|
|
1203
|
+
operationName: operationMatch[1],
|
|
1204
|
+
operationType: typeMatch ? typeMatch[1] : 'query',
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
// Pattern 3: Look at type definitions
|
|
1210
|
+
// export type SomeQuery = { ... }
|
|
1211
|
+
// This indicates SomeQueryDocument exists
|
|
1212
|
+
const typeAliases = sourceFile.getTypeAliases();
|
|
1213
|
+
for (const typeAlias of typeAliases) {
|
|
1214
|
+
const name = typeAlias.getName();
|
|
1215
|
+
if ((name.endsWith('Query') ||
|
|
1216
|
+
name.endsWith('Mutation') ||
|
|
1217
|
+
name.endsWith('Subscription')) &&
|
|
1218
|
+
!name.endsWith('Variables')) {
|
|
1219
|
+
const docName = name + 'Document';
|
|
1220
|
+
if (!this.codegenMap.has(docName)) {
|
|
1221
|
+
const operationType = name.endsWith('Mutation')
|
|
1222
|
+
? 'mutation'
|
|
1223
|
+
: name.endsWith('Subscription')
|
|
1224
|
+
? 'subscription'
|
|
1225
|
+
: 'query';
|
|
1226
|
+
this.codegenMap.set(docName, {
|
|
1227
|
+
operationName: name,
|
|
1228
|
+
operationType,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
// Skip file on error
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (this.codegenMap.size > 0) {
|
|
1239
|
+
this.log(`Loaded ${this.codegenMap.size} codegen mappings from ${dir}`);
|
|
1240
|
+
return; // Found and loaded, no need to check other dirs
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// Directory doesn't exist, continue
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Resolve Document name to operation name using codegen map
|
|
1250
|
+
*/
|
|
1251
|
+
resolveDocumentName(documentName) {
|
|
1252
|
+
// Skip generic GraphQL type names - these are not actual operation names
|
|
1253
|
+
const genericTypeNames = new Set(['Query', 'Mutation', 'Subscription']);
|
|
1254
|
+
if (genericTypeNames.has(documentName)) {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
// Direct lookup
|
|
1258
|
+
const directResult = this.codegenMap.get(documentName);
|
|
1259
|
+
if (directResult !== undefined) {
|
|
1260
|
+
return directResult;
|
|
1261
|
+
}
|
|
1262
|
+
// Try with Document suffix
|
|
1263
|
+
const withSuffix = documentName.endsWith('Document') ? documentName : documentName + 'Document';
|
|
1264
|
+
const suffixResult = this.codegenMap.get(withSuffix);
|
|
1265
|
+
if (suffixResult !== undefined) {
|
|
1266
|
+
return suffixResult;
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Follow re-export in index file to find the actual component file
|
|
1272
|
+
*/
|
|
1273
|
+
followReExport(indexFile, componentName, indexDir) {
|
|
1274
|
+
try {
|
|
1275
|
+
// Look for export declarations that export the component
|
|
1276
|
+
// e.g., export { ProfilePreviewContainer } from "./ProfilePreviewContainer"
|
|
1277
|
+
const exportDecls = indexFile.getExportDeclarations();
|
|
1278
|
+
let firstExportFile = null;
|
|
1279
|
+
for (const exportDecl of exportDecls) {
|
|
1280
|
+
const namedExports = exportDecl.getNamedExports();
|
|
1281
|
+
for (const named of namedExports) {
|
|
1282
|
+
const moduleSpec = exportDecl.getModuleSpecifierValue();
|
|
1283
|
+
if (!moduleSpec)
|
|
1284
|
+
continue;
|
|
1285
|
+
const resolvedPath = path.resolve(indexDir, moduleSpec);
|
|
1286
|
+
const possiblePaths = [
|
|
1287
|
+
`${resolvedPath}.tsx`,
|
|
1288
|
+
`${resolvedPath}.ts`,
|
|
1289
|
+
`${resolvedPath}/index.tsx`,
|
|
1290
|
+
`${resolvedPath}/index.ts`,
|
|
1291
|
+
];
|
|
1292
|
+
// Try to load the file
|
|
1293
|
+
let file;
|
|
1294
|
+
for (const tryPath of possiblePaths) {
|
|
1295
|
+
try {
|
|
1296
|
+
file = this.project.addSourceFileAtPath(tryPath);
|
|
1297
|
+
if (file)
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
// Try next path
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (!file)
|
|
1305
|
+
continue;
|
|
1306
|
+
// Store first export as fallback (for default imports with different names)
|
|
1307
|
+
if (!firstExportFile) {
|
|
1308
|
+
firstExportFile = file;
|
|
1309
|
+
}
|
|
1310
|
+
// Check if this is the exact component we're looking for
|
|
1311
|
+
if (named.getName() === componentName ||
|
|
1312
|
+
named.getAliasNode()?.getText() === componentName) {
|
|
1313
|
+
return file;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// If exact match not found, return first export (handles default imports)
|
|
1318
|
+
if (firstExportFile) {
|
|
1319
|
+
return firstExportFile;
|
|
1320
|
+
}
|
|
1321
|
+
// Also check for: export * from "./SomeModule"
|
|
1322
|
+
for (const exportDecl of indexFile.getExportDeclarations()) {
|
|
1323
|
+
if (exportDecl.isNamespaceExport()) {
|
|
1324
|
+
const moduleSpec = exportDecl.getModuleSpecifierValue();
|
|
1325
|
+
if (moduleSpec) {
|
|
1326
|
+
const resolvedPath = path.resolve(indexDir, moduleSpec);
|
|
1327
|
+
const possiblePaths = [`${resolvedPath}.tsx`, `${resolvedPath}.ts`];
|
|
1328
|
+
for (const tryPath of possiblePaths) {
|
|
1329
|
+
try {
|
|
1330
|
+
const file = this.project.addSourceFileAtPath(tryPath);
|
|
1331
|
+
if (file) {
|
|
1332
|
+
// Check if this file exports the component
|
|
1333
|
+
const hasExport = file.getExportedDeclarations().has(componentName);
|
|
1334
|
+
if (hasExport)
|
|
1335
|
+
return file;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
// Try next path
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
// Failed to follow re-export
|
|
1348
|
+
}
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Check if a name looks like a React component (PascalCase with common suffixes)
|
|
1353
|
+
*/
|
|
1354
|
+
isComponentName(name) {
|
|
1355
|
+
// Must be PascalCase (start with uppercase)
|
|
1356
|
+
if (!/^[A-Z]/.test(name))
|
|
1357
|
+
return false;
|
|
1358
|
+
// Exclude GraphQL-related type names (not actual components)
|
|
1359
|
+
// These are generated types from codegen or GraphQL operations
|
|
1360
|
+
if (name.endsWith('Query') ||
|
|
1361
|
+
name.endsWith('Mutation') ||
|
|
1362
|
+
name.endsWith('Subscription') ||
|
|
1363
|
+
name.endsWith('Fragment') ||
|
|
1364
|
+
name.endsWith('Document') ||
|
|
1365
|
+
name.endsWith('Variables') ||
|
|
1366
|
+
name === 'Query' ||
|
|
1367
|
+
name === 'Mutation' ||
|
|
1368
|
+
name === 'Subscription') {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
// Exclude React/Next.js type definitions (not actual components)
|
|
1372
|
+
const typeDefinitions = new Set([
|
|
1373
|
+
'NextPage',
|
|
1374
|
+
'NextPageContext',
|
|
1375
|
+
'NextApiRequest',
|
|
1376
|
+
'NextApiResponse',
|
|
1377
|
+
'GetServerSideProps',
|
|
1378
|
+
'GetStaticProps',
|
|
1379
|
+
'GetStaticPaths',
|
|
1380
|
+
'InferGetServerSidePropsType',
|
|
1381
|
+
'InferGetStaticPropsType',
|
|
1382
|
+
'FC',
|
|
1383
|
+
'FunctionComponent',
|
|
1384
|
+
'VFC',
|
|
1385
|
+
'Component',
|
|
1386
|
+
'PureComponent',
|
|
1387
|
+
'ReactNode',
|
|
1388
|
+
'ReactElement',
|
|
1389
|
+
'PropsWithChildren',
|
|
1390
|
+
'ComponentProps',
|
|
1391
|
+
'ComponentType',
|
|
1392
|
+
'ElementType',
|
|
1393
|
+
'RefObject',
|
|
1394
|
+
'MutableRefObject',
|
|
1395
|
+
'Dispatch',
|
|
1396
|
+
'SetStateAction',
|
|
1397
|
+
'ChangeEvent',
|
|
1398
|
+
'MouseEvent',
|
|
1399
|
+
'KeyboardEvent',
|
|
1400
|
+
'FormEvent',
|
|
1401
|
+
'SyntheticEvent',
|
|
1402
|
+
]);
|
|
1403
|
+
if (typeDefinitions.has(name)) {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
// Common component suffixes that likely contain data fetching
|
|
1407
|
+
const componentSuffixes = [
|
|
1408
|
+
'Container',
|
|
1409
|
+
'Page',
|
|
1410
|
+
'Screen',
|
|
1411
|
+
'View',
|
|
1412
|
+
'Form',
|
|
1413
|
+
'Modal',
|
|
1414
|
+
'Dialog',
|
|
1415
|
+
'Panel',
|
|
1416
|
+
'Root',
|
|
1417
|
+
'Provider',
|
|
1418
|
+
'Wrapper',
|
|
1419
|
+
];
|
|
1420
|
+
// Check for suffix match
|
|
1421
|
+
if (componentSuffixes.some((suffix) => name.endsWith(suffix))) {
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
// Also match if it ends with Page-like patterns
|
|
1425
|
+
if (/Page[A-Z]?\w*$/.test(name) || /Container[A-Z]?\w*$/.test(name)) {
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
// Match PascalCase names that look like feature components
|
|
1429
|
+
// e.g., CancellationEngagement, UserProfile, ProjectSettings
|
|
1430
|
+
if (/^[A-Z][a-z]+[A-Z][a-z]+/.test(name)) {
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
extractNavigation(sourceFile) {
|
|
1436
|
+
const result = {
|
|
1437
|
+
visible: true,
|
|
1438
|
+
currentNavItem: null,
|
|
1439
|
+
};
|
|
1440
|
+
try {
|
|
1441
|
+
// Find globalNavigationStyle assignment
|
|
1442
|
+
const navStyleAssignment = sourceFile
|
|
1443
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
1444
|
+
.find((node) => {
|
|
1445
|
+
try {
|
|
1446
|
+
return node.getName() === 'globalNavigationStyle';
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
if (navStyleAssignment) {
|
|
1453
|
+
const parent = navStyleAssignment.getParent();
|
|
1454
|
+
if (Node.isBinaryExpression(parent)) {
|
|
1455
|
+
const right = parent.getRight();
|
|
1456
|
+
// Extract visible
|
|
1457
|
+
const visibleProp = right
|
|
1458
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
1459
|
+
.find((prop) => {
|
|
1460
|
+
try {
|
|
1461
|
+
return prop.getName() === 'visible';
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
if (visibleProp) {
|
|
1468
|
+
result.visible = visibleProp.getInitializer()?.getText() === 'true';
|
|
1469
|
+
}
|
|
1470
|
+
// Extract currentNavItem
|
|
1471
|
+
const navItemProp = right
|
|
1472
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
1473
|
+
.find((prop) => {
|
|
1474
|
+
try {
|
|
1475
|
+
return prop.getName() === 'currentNavItem';
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
if (navItemProp) {
|
|
1482
|
+
const value = navItemProp.getInitializer()?.getText();
|
|
1483
|
+
result.currentNavItem = value && value !== 'null' ? value.replace(/['"]/g, '') : null;
|
|
1484
|
+
}
|
|
1485
|
+
// Extract mini
|
|
1486
|
+
const miniProp = right
|
|
1487
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
1488
|
+
.find((prop) => {
|
|
1489
|
+
try {
|
|
1490
|
+
return prop.getName() === 'mini';
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
if (miniProp) {
|
|
1497
|
+
result.mini = miniProp.getInitializer()?.getText() === 'true';
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
catch {
|
|
1503
|
+
// Return default on error
|
|
1504
|
+
}
|
|
1505
|
+
return result;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Extract multi-step flow information (wizard, stepper, onboarding)
|
|
1509
|
+
*/
|
|
1510
|
+
extractSteps(sourceFile) {
|
|
1511
|
+
const steps = [];
|
|
1512
|
+
// Pattern 1: useState with step-like variable names
|
|
1513
|
+
const useStateCalls = sourceFile
|
|
1514
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
1515
|
+
.filter((call) => call.getExpression().getText() === 'useState');
|
|
1516
|
+
for (const call of useStateCalls) {
|
|
1517
|
+
const parent = call.getParent();
|
|
1518
|
+
if (!parent)
|
|
1519
|
+
continue;
|
|
1520
|
+
const parentText = parent.getText();
|
|
1521
|
+
// Match: const [step, setStep] = useState(0) or [currentStep, setCurrentStep]
|
|
1522
|
+
const stepMatch = parentText.match(/\[\s*(step|currentStep|activeStep|page|currentPage|phase|stage)\s*,/i);
|
|
1523
|
+
if (stepMatch) {
|
|
1524
|
+
// Found a step state, now look for step-related JSX or switch cases
|
|
1525
|
+
const stepVarName = stepMatch[1];
|
|
1526
|
+
// Look for switch statements or conditional rendering
|
|
1527
|
+
const switchStatements = sourceFile.getDescendantsOfKind(SyntaxKind.SwitchStatement);
|
|
1528
|
+
for (const switchStmt of switchStatements) {
|
|
1529
|
+
const expression = switchStmt.getExpression().getText();
|
|
1530
|
+
if (expression.includes(stepVarName)) {
|
|
1531
|
+
const caseBlocks = switchStmt.getClauses();
|
|
1532
|
+
caseBlocks.forEach((clause, idx) => {
|
|
1533
|
+
if (clause.isKind(SyntaxKind.CaseClause)) {
|
|
1534
|
+
const caseExpr = clause.getExpression()?.getText() || String(idx);
|
|
1535
|
+
// Try to find component name in case block
|
|
1536
|
+
const jsxElements = clause.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
|
|
1537
|
+
const componentName = jsxElements.length > 0 ? jsxElements[0].getTagNameNode().getText() : undefined;
|
|
1538
|
+
steps.push({
|
|
1539
|
+
id: caseExpr.replace(/['"]/g, ''),
|
|
1540
|
+
name: `Step ${caseExpr.replace(/['"]/g, '')}`,
|
|
1541
|
+
component: componentName,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// Look for array of steps/components
|
|
1548
|
+
const arrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression);
|
|
1549
|
+
for (const arr of arrayLiterals) {
|
|
1550
|
+
const parentVar = arr.getParent();
|
|
1551
|
+
if (parentVar && parentVar.getText().match(/steps|pages|screens|views|components/i)) {
|
|
1552
|
+
const elements = arr.getElements();
|
|
1553
|
+
elements.forEach((el, idx) => {
|
|
1554
|
+
const elText = el.getText();
|
|
1555
|
+
// Could be component reference or object
|
|
1556
|
+
if (elText.startsWith('{')) {
|
|
1557
|
+
// Object literal, try to extract name/label
|
|
1558
|
+
const nameMatch = elText.match(/(?:name|label|title)\s*:\s*['"]([^'"]+)['"]/);
|
|
1559
|
+
const compMatch = elText.match(/(?:component|content)\s*:\s*<?\s*(\w+)/);
|
|
1560
|
+
steps.push({
|
|
1561
|
+
id: idx + 1,
|
|
1562
|
+
name: nameMatch ? nameMatch[1] : `Step ${idx + 1}`,
|
|
1563
|
+
component: compMatch ? compMatch[1] : undefined,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
else if (/^[A-Z]/.test(elText)) {
|
|
1567
|
+
// Component reference
|
|
1568
|
+
steps.push({
|
|
1569
|
+
id: idx + 1,
|
|
1570
|
+
name: elText,
|
|
1571
|
+
component: elText,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
// Pattern 2: Stepper/Wizard component usage
|
|
1580
|
+
const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
|
|
1581
|
+
for (const jsx of jsxElements) {
|
|
1582
|
+
const tagName = jsx.getTagNameNode().getText();
|
|
1583
|
+
if (tagName.match(/Stepper|Wizard|Steps|TabPanel|FormStep/i)) {
|
|
1584
|
+
// Find Step children
|
|
1585
|
+
const parent = jsx.getParent();
|
|
1586
|
+
if (parent && parent.isKind(SyntaxKind.JsxElement)) {
|
|
1587
|
+
const children = parent.getJsxChildren();
|
|
1588
|
+
children.forEach((child, idx) => {
|
|
1589
|
+
if (child.isKind(SyntaxKind.JsxElement) ||
|
|
1590
|
+
child.isKind(SyntaxKind.JsxSelfClosingElement)) {
|
|
1591
|
+
const childTag = child.isKind(SyntaxKind.JsxElement)
|
|
1592
|
+
? child.getOpeningElement().getTagNameNode().getText()
|
|
1593
|
+
: child.getTagNameNode().getText();
|
|
1594
|
+
// Get label/title attribute
|
|
1595
|
+
const attrs = child.isKind(SyntaxKind.JsxElement)
|
|
1596
|
+
? child.getOpeningElement().getAttributes()
|
|
1597
|
+
: child.getAttributes();
|
|
1598
|
+
let stepName = childTag;
|
|
1599
|
+
for (const attr of attrs) {
|
|
1600
|
+
if (attr.isKind(SyntaxKind.JsxAttribute)) {
|
|
1601
|
+
const name = attr.getNameNode().getText();
|
|
1602
|
+
if (name === 'label' || name === 'title' || name === 'name') {
|
|
1603
|
+
const value = attr.getInitializer()?.getText();
|
|
1604
|
+
if (value) {
|
|
1605
|
+
stepName = value.replace(/['"{}]/g, '');
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
steps.push({
|
|
1612
|
+
id: idx + 1,
|
|
1613
|
+
name: stepName,
|
|
1614
|
+
component: childTag,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
// Pattern 3: Conditional rendering with step variable
|
|
1622
|
+
const conditionalExprs = sourceFile.getDescendantsOfKind(SyntaxKind.ConditionalExpression);
|
|
1623
|
+
for (const cond of conditionalExprs) {
|
|
1624
|
+
const condition = cond.getCondition().getText();
|
|
1625
|
+
if (condition.match(/step\s*===?\s*\d+|currentStep|activeStep/i)) {
|
|
1626
|
+
// Extract step number and components
|
|
1627
|
+
const whenTrue = cond.getWhenTrue();
|
|
1628
|
+
// Note: whenFalse (cond.getWhenFalse()) could be used for nested step detection
|
|
1629
|
+
const stepNumMatch = condition.match(/===?\s*(\d+)/);
|
|
1630
|
+
if (stepNumMatch && steps.length === 0) {
|
|
1631
|
+
// Only add if we haven't found steps through other patterns
|
|
1632
|
+
const trueJsx = whenTrue.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
|
|
1633
|
+
if (trueJsx.length > 0) {
|
|
1634
|
+
steps.push({
|
|
1635
|
+
id: parseInt(stepNumMatch[1]),
|
|
1636
|
+
component: trueJsx[0].getTagNameNode().getText(),
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return steps;
|
|
1643
|
+
}
|
|
1644
|
+
extractLinkedPages(sourceFile) {
|
|
1645
|
+
const linkedPages = [];
|
|
1646
|
+
// Find router.push/replace calls
|
|
1647
|
+
const routerCalls = sourceFile
|
|
1648
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
1649
|
+
.filter((call) => {
|
|
1650
|
+
const text = call.getExpression().getText();
|
|
1651
|
+
return (text.includes('router.push') || text.includes('router.replace') || text.includes('Link'));
|
|
1652
|
+
});
|
|
1653
|
+
for (const call of routerCalls) {
|
|
1654
|
+
const args = call.getArguments();
|
|
1655
|
+
if (args.length > 0) {
|
|
1656
|
+
const pathArg = args[0].getText();
|
|
1657
|
+
// Extract path string
|
|
1658
|
+
const pathMatch = pathArg.match(/['"`]([^'"`]+)['"`]/);
|
|
1659
|
+
if (pathMatch && !linkedPages.includes(pathMatch[1])) {
|
|
1660
|
+
linkedPages.push(pathMatch[1]);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
// Find Link components
|
|
1665
|
+
const linkElements = sourceFile
|
|
1666
|
+
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
|
|
1667
|
+
.filter((node) => node.getTagNameNode().getText() === 'Link');
|
|
1668
|
+
for (const link of linkElements) {
|
|
1669
|
+
try {
|
|
1670
|
+
// Get all attributes and filter for JsxAttribute type (not JsxSpreadAttribute)
|
|
1671
|
+
const attributes = link.getAttributes();
|
|
1672
|
+
for (const attr of attributes) {
|
|
1673
|
+
// Check if it's a JsxAttribute
|
|
1674
|
+
if (attr.isKind(SyntaxKind.JsxAttribute)) {
|
|
1675
|
+
const nameNode = attr.getNameNode();
|
|
1676
|
+
const name = nameNode.getText();
|
|
1677
|
+
if (name === 'href') {
|
|
1678
|
+
const value = attr.getInitializer()?.getText();
|
|
1679
|
+
if (value) {
|
|
1680
|
+
const pathMatch = value.match(/['"`]([^'"`]+)['"`]/);
|
|
1681
|
+
if (pathMatch && !linkedPages.includes(pathMatch[1])) {
|
|
1682
|
+
linkedPages.push(pathMatch[1]);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
catch {
|
|
1690
|
+
// Skip if attribute extraction fails
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return linkedPages;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
//# sourceMappingURL=pages-analyzer.js.map
|