@toolbaux/guardian 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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/adapters/csharp-adapter.js +149 -0
  4. package/dist/adapters/go-adapter.js +96 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/java-adapter.js +122 -0
  7. package/dist/adapters/python-adapter.js +183 -0
  8. package/dist/adapters/runner.js +69 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/adapters/typescript-adapter.js +179 -0
  11. package/dist/benchmarking/framework.js +91 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/commands/analyze-depth.js +43 -0
  14. package/dist/commands/api-spec-extractor.js +52 -0
  15. package/dist/commands/breaking-change-analyzer.js +334 -0
  16. package/dist/commands/config-compliance.js +219 -0
  17. package/dist/commands/constraints.js +221 -0
  18. package/dist/commands/context.js +101 -0
  19. package/dist/commands/data-flow-tracer.js +291 -0
  20. package/dist/commands/dependency-impact-analyzer.js +27 -0
  21. package/dist/commands/diff.js +146 -0
  22. package/dist/commands/discrepancy.js +71 -0
  23. package/dist/commands/doc-generate.js +163 -0
  24. package/dist/commands/doc-html.js +120 -0
  25. package/dist/commands/drift.js +88 -0
  26. package/dist/commands/extract.js +16 -0
  27. package/dist/commands/feature-context.js +116 -0
  28. package/dist/commands/generate.js +339 -0
  29. package/dist/commands/guard.js +182 -0
  30. package/dist/commands/init.js +209 -0
  31. package/dist/commands/intel.js +20 -0
  32. package/dist/commands/license-dependency-auditor.js +33 -0
  33. package/dist/commands/performance-hotspot-profiler.js +42 -0
  34. package/dist/commands/search.js +314 -0
  35. package/dist/commands/security-boundary-auditor.js +359 -0
  36. package/dist/commands/simulate.js +294 -0
  37. package/dist/commands/summary.js +27 -0
  38. package/dist/commands/test-coverage-mapper.js +264 -0
  39. package/dist/commands/verify-drift.js +62 -0
  40. package/dist/config.js +441 -0
  41. package/dist/extract/ai-context-hints.js +107 -0
  42. package/dist/extract/analyzers/backend.js +1704 -0
  43. package/dist/extract/analyzers/depth.js +264 -0
  44. package/dist/extract/analyzers/frontend.js +2221 -0
  45. package/dist/extract/api-usage-tracker.js +19 -0
  46. package/dist/extract/cache.js +53 -0
  47. package/dist/extract/codebase-intel.js +190 -0
  48. package/dist/extract/compress.js +452 -0
  49. package/dist/extract/context-block.js +356 -0
  50. package/dist/extract/contracts.js +183 -0
  51. package/dist/extract/discrepancies.js +233 -0
  52. package/dist/extract/docs-loader.js +110 -0
  53. package/dist/extract/docs.js +2379 -0
  54. package/dist/extract/drift.js +1578 -0
  55. package/dist/extract/duplicates.js +435 -0
  56. package/dist/extract/feature-arcs.js +138 -0
  57. package/dist/extract/graph.js +76 -0
  58. package/dist/extract/html-doc.js +1409 -0
  59. package/dist/extract/ignore.js +45 -0
  60. package/dist/extract/index.js +455 -0
  61. package/dist/extract/llm-client.js +159 -0
  62. package/dist/extract/pattern-registry.js +141 -0
  63. package/dist/extract/product-doc.js +497 -0
  64. package/dist/extract/python.js +1202 -0
  65. package/dist/extract/runtime.js +193 -0
  66. package/dist/extract/schema-evolution-validator.js +35 -0
  67. package/dist/extract/test-gap-analyzer.js +20 -0
  68. package/dist/extract/tests.js +74 -0
  69. package/dist/extract/types.js +1 -0
  70. package/dist/extract/validate-backend.js +30 -0
  71. package/dist/extract/writer.js +11 -0
  72. package/dist/output-layout.js +37 -0
  73. package/dist/project-discovery.js +309 -0
  74. package/dist/schema/architecture.js +350 -0
  75. package/dist/schema/feature-spec.js +89 -0
  76. package/dist/schema/index.js +8 -0
  77. package/dist/schema/ux.js +46 -0
  78. package/package.json +75 -0
@@ -0,0 +1,2221 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import { getAdapterForFile, runAdapter } from "../../adapters/index.js";
5
+ import { addEdge, ensureNode, inboundCounts } from "../graph.js";
6
+ import { createIgnoreMatcher } from "../ignore.js";
7
+ const PAGE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mdx"]);
8
+ const CODE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mdx"]);
9
+ const JS_RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mdx"];
10
+ const ROUTE_FILE_BASENAMES = new Set([
11
+ "page",
12
+ "layout",
13
+ "template",
14
+ "loading",
15
+ "error",
16
+ "not-found",
17
+ "route"
18
+ ]);
19
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
20
+ const NAVIGATION_METHODS = new Set(["push", "replace"]);
21
+ const LINK_TAGS = new Set(["Link", "NavLink", "a"]);
22
+ const UTILITY_NAME_PATTERNS = [
23
+ /^Icon$/i,
24
+ /^Icons$/i,
25
+ /Icon$/i,
26
+ /^Icon[A-Z]/,
27
+ /^Icons[A-Z]/,
28
+ /^(Spinner|Loader|Skeleton|Divider|Separator|Spacer|SrOnly|VisuallyHidden)$/i
29
+ ];
30
+ const UTILITY_PATH_SEGMENTS = new Set([
31
+ "icon",
32
+ "icons",
33
+ "utils",
34
+ "utility",
35
+ "utilities",
36
+ "helpers",
37
+ "helper"
38
+ ]);
39
+ const ROUTER_FACTORY_NAMES = new Set([
40
+ "createBrowserRouter",
41
+ "createHashRouter",
42
+ "createMemoryRouter",
43
+ "createRoutesFromElements"
44
+ ]);
45
+ const EXPO_ROUTER_SKIP_BASENAMES = new Set(["_layout", "_error", "+not-found", "+html"]);
46
+ async function detectFileRouterFramework(frontendRoot) {
47
+ const root = path.resolve(frontendRoot);
48
+ try {
49
+ const pkgPath = path.join(root, "package.json");
50
+ const raw = await fs.readFile(pkgPath, "utf8");
51
+ const pkg = JSON.parse(raw);
52
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
53
+ if (allDeps["expo-router"])
54
+ return "expo-router";
55
+ if (allDeps["next"])
56
+ return "next";
57
+ }
58
+ catch {
59
+ // no package.json or parse failure
60
+ }
61
+ return null;
62
+ }
63
+ function isExpoRouterPage(filePath, appDir) {
64
+ const ext = path.extname(filePath);
65
+ if (!PAGE_EXTENSIONS.has(ext))
66
+ return false;
67
+ const base = path.basename(filePath, ext);
68
+ if (EXPO_ROUTER_SKIP_BASENAMES.has(base))
69
+ return false;
70
+ // Must be inside the app directory
71
+ const relative = path.relative(appDir, filePath);
72
+ return !relative.startsWith("..");
73
+ }
74
+ function expoRouteFromFile(appDir, filePath) {
75
+ const ext = path.extname(filePath);
76
+ const base = path.basename(filePath, ext);
77
+ const relativeDir = path.relative(appDir, path.dirname(filePath));
78
+ // index.tsx → /
79
+ if (base === "index" && (!relativeDir || relativeDir === "."))
80
+ return "/";
81
+ const segments = relativeDir
82
+ .split(path.sep)
83
+ .filter(Boolean)
84
+ .filter((s) => !isRouteGroup(s));
85
+ // index.tsx in a subdirectory → /parent/child
86
+ if (base === "index") {
87
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
88
+ }
89
+ // session.tsx in child/ → /child/session
90
+ segments.push(base);
91
+ return `/${segments.join("/")}`;
92
+ }
93
+ function toPosix(p) {
94
+ return p.split(path.sep).join("/");
95
+ }
96
+ function isCodeFile(filePath) {
97
+ return CODE_EXTENSIONS.has(path.extname(filePath));
98
+ }
99
+ function isTsProgramFile(filePath) {
100
+ const ext = path.extname(filePath);
101
+ return ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs";
102
+ }
103
+ async function existsDir(dir) {
104
+ try {
105
+ const stat = await fs.stat(dir);
106
+ return stat.isDirectory();
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ async function findAppDir(frontendRoot) {
113
+ const root = path.resolve(frontendRoot);
114
+ const candidates = [path.join(root, "src", "app"), path.join(root, "app")];
115
+ for (const candidate of candidates) {
116
+ if (await existsDir(candidate)) {
117
+ return candidate;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ async function listFiles(root, ignore, baseRoot) {
123
+ const entries = await fs.readdir(root, { withFileTypes: true });
124
+ const files = [];
125
+ for (const entry of entries) {
126
+ const fullPath = path.join(root, entry.name);
127
+ if (entry.isDirectory()) {
128
+ if (ignore.isIgnoredDir(entry.name, fullPath)) {
129
+ continue;
130
+ }
131
+ files.push(...(await listFiles(fullPath, ignore, baseRoot)));
132
+ continue;
133
+ }
134
+ if (entry.isFile()) {
135
+ const relative = path.relative(baseRoot, fullPath);
136
+ if (!ignore.isIgnoredPath(relative)) {
137
+ files.push(fullPath);
138
+ }
139
+ }
140
+ }
141
+ return files;
142
+ }
143
+ async function fileExists(filePath) {
144
+ try {
145
+ const stat = await fs.stat(filePath);
146
+ return stat.isFile();
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ async function resolveFileCandidate(basePath, extensions) {
153
+ const ext = path.extname(basePath);
154
+ if (ext) {
155
+ if (await fileExists(basePath)) {
156
+ return basePath;
157
+ }
158
+ const withoutExt = basePath.slice(0, -ext.length);
159
+ for (const replacement of extensions) {
160
+ const candidate = `${withoutExt}${replacement}`;
161
+ if (await fileExists(candidate)) {
162
+ return candidate;
163
+ }
164
+ }
165
+ for (const replacement of extensions) {
166
+ const candidate = path.join(withoutExt, `index${replacement}`);
167
+ if (await fileExists(candidate)) {
168
+ return candidate;
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+ for (const replacement of extensions) {
174
+ const candidate = `${basePath}${replacement}`;
175
+ if (await fileExists(candidate)) {
176
+ return candidate;
177
+ }
178
+ }
179
+ for (const replacement of extensions) {
180
+ const candidate = path.join(basePath, `index${replacement}`);
181
+ if (await fileExists(candidate)) {
182
+ return candidate;
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+ async function resolveJsImport(fromFile, specifier, aliases) {
188
+ if (specifier.startsWith(".")) {
189
+ const resolved = path.resolve(path.dirname(fromFile), specifier);
190
+ return resolveFileCandidate(resolved, JS_RESOLVE_EXTENSIONS);
191
+ }
192
+ const aliasResolved = resolveAliasImport(specifier, aliases);
193
+ if (aliasResolved) {
194
+ return resolveFileCandidate(aliasResolved, JS_RESOLVE_EXTENSIONS);
195
+ }
196
+ return null;
197
+ }
198
+ function scriptKindFromPath(filePath) {
199
+ const ext = path.extname(filePath).toLowerCase();
200
+ switch (ext) {
201
+ case ".tsx":
202
+ return ts.ScriptKind.TSX;
203
+ case ".jsx":
204
+ return ts.ScriptKind.JSX;
205
+ case ".js":
206
+ case ".mjs":
207
+ case ".cjs":
208
+ return ts.ScriptKind.JS;
209
+ case ".ts":
210
+ default:
211
+ return ts.ScriptKind.TS;
212
+ }
213
+ }
214
+ function getStringLiteral(node) {
215
+ if (!node) {
216
+ return null;
217
+ }
218
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
219
+ return node.text;
220
+ }
221
+ return null;
222
+ }
223
+ function templateExpressionText(node) {
224
+ let result = node.head.text;
225
+ for (const span of node.templateSpans) {
226
+ result += "${" + span.expression.getText() + "}" + span.literal.text;
227
+ }
228
+ return result;
229
+ }
230
+ function getExpressionText(node) {
231
+ if (!node) {
232
+ return null;
233
+ }
234
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
235
+ return node.text;
236
+ }
237
+ if (ts.isTemplateExpression(node)) {
238
+ return templateExpressionText(node);
239
+ }
240
+ return null;
241
+ }
242
+ function isPascalCase(name) {
243
+ return !!name && name[0] === name[0].toUpperCase();
244
+ }
245
+ function containsJsx(node) {
246
+ let found = false;
247
+ const visit = (child) => {
248
+ if (ts.isJsxElement(child) ||
249
+ ts.isJsxSelfClosingElement(child) ||
250
+ ts.isJsxFragment(child)) {
251
+ found = true;
252
+ return;
253
+ }
254
+ ts.forEachChild(child, visit);
255
+ };
256
+ visit(node);
257
+ return found;
258
+ }
259
+ function normalizeAliasKey(key) {
260
+ return key.replace(/\/\*$/, "");
261
+ }
262
+ function normalizeAliasTarget(target) {
263
+ return target.replace(/\/\*$/, "");
264
+ }
265
+ function resolveAliasImport(specifier, aliases) {
266
+ for (const alias of aliases) {
267
+ if (specifier === alias.alias) {
268
+ return alias.target;
269
+ }
270
+ if (specifier.startsWith(`${alias.alias}/`)) {
271
+ const rest = specifier.slice(alias.alias.length + 1);
272
+ return path.join(alias.target, rest);
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+ async function loadAliasMappings(root, config) {
278
+ const merged = new Map();
279
+ const tsconfigAliases = await readTsconfigAliases(root, config.frontend?.tsconfigPath);
280
+ for (const [alias, target] of tsconfigAliases) {
281
+ merged.set(alias, target);
282
+ }
283
+ const configAliases = config.frontend?.aliases ?? {};
284
+ for (const [alias, target] of Object.entries(configAliases)) {
285
+ const normalizedAlias = normalizeAliasKey(alias.trim());
286
+ const normalizedTarget = normalizeAliasTarget(target.trim());
287
+ if (!normalizedAlias || !normalizedTarget) {
288
+ continue;
289
+ }
290
+ const absoluteTarget = path.isAbsolute(normalizedTarget)
291
+ ? normalizedTarget
292
+ : path.resolve(root, normalizedTarget);
293
+ merged.set(normalizedAlias, absoluteTarget);
294
+ }
295
+ return Array.from(merged.entries())
296
+ .map(([alias, target]) => ({ alias, target }))
297
+ .sort((a, b) => b.alias.length - a.alias.length);
298
+ }
299
+ async function readTsconfigAliases(root, tsconfigPath) {
300
+ const result = new Map();
301
+ const resolvedPath = await resolveTsconfigPath(root, tsconfigPath);
302
+ if (!resolvedPath) {
303
+ return result;
304
+ }
305
+ const configFile = ts.readConfigFile(resolvedPath, ts.sys.readFile);
306
+ if (configFile.error || !configFile.config) {
307
+ return result;
308
+ }
309
+ const compilerOptions = configFile.config.compilerOptions ?? {};
310
+ const baseUrl = compilerOptions.baseUrl ?? ".";
311
+ const paths = compilerOptions.paths ?? {};
312
+ const baseDir = path.resolve(path.dirname(resolvedPath), baseUrl);
313
+ for (const [aliasPattern, targetPatterns] of Object.entries(paths)) {
314
+ if (!Array.isArray(targetPatterns) || targetPatterns.length === 0) {
315
+ continue;
316
+ }
317
+ const targetPattern = targetPatterns[0];
318
+ if (typeof targetPattern !== "string") {
319
+ continue;
320
+ }
321
+ const alias = normalizeAliasKey(aliasPattern);
322
+ const target = normalizeAliasTarget(targetPattern);
323
+ if (!alias || !target) {
324
+ continue;
325
+ }
326
+ const absoluteTarget = path.isAbsolute(target)
327
+ ? target
328
+ : path.resolve(baseDir, target);
329
+ result.set(alias, absoluteTarget);
330
+ }
331
+ return result;
332
+ }
333
+ async function loadTsCompilerOptions(root, tsconfigPath) {
334
+ const resolvedPath = await resolveTsconfigPath(root, tsconfigPath);
335
+ if (!resolvedPath) {
336
+ return {
337
+ allowJs: true,
338
+ jsx: ts.JsxEmit.ReactJSX,
339
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
340
+ module: ts.ModuleKind.NodeNext,
341
+ resolveJsonModule: true,
342
+ skipLibCheck: true
343
+ };
344
+ }
345
+ const configFile = ts.readConfigFile(resolvedPath, ts.sys.readFile);
346
+ if (configFile.error || !configFile.config) {
347
+ return {
348
+ allowJs: true,
349
+ jsx: ts.JsxEmit.ReactJSX,
350
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
351
+ module: ts.ModuleKind.NodeNext,
352
+ resolveJsonModule: true,
353
+ skipLibCheck: true
354
+ };
355
+ }
356
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(resolvedPath));
357
+ return {
358
+ ...parsed.options,
359
+ allowJs: true,
360
+ jsx: parsed.options.jsx ?? ts.JsxEmit.ReactJSX,
361
+ moduleResolution: parsed.options.moduleResolution ?? ts.ModuleResolutionKind.NodeNext,
362
+ module: parsed.options.module ?? ts.ModuleKind.NodeNext,
363
+ resolveJsonModule: true,
364
+ skipLibCheck: true
365
+ };
366
+ }
367
+ async function resolveTsconfigPath(root, tsconfigPath) {
368
+ if (tsconfigPath && tsconfigPath.trim()) {
369
+ const resolved = path.isAbsolute(tsconfigPath) ? tsconfigPath : path.resolve(root, tsconfigPath);
370
+ if (await fileExists(resolved)) {
371
+ return resolved;
372
+ }
373
+ return null;
374
+ }
375
+ const candidates = ["tsconfig.json", "jsconfig.json"].map((name) => path.join(root, name));
376
+ for (const candidate of candidates) {
377
+ if (await fileExists(candidate)) {
378
+ return candidate;
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ function joinRoutePaths(parent, child) {
384
+ const cleanedParent = parent === "/" ? "" : parent.replace(/\/$/, "");
385
+ if (!child) {
386
+ return cleanedParent || "/";
387
+ }
388
+ if (child.startsWith("/")) {
389
+ return child;
390
+ }
391
+ if (child === "*") {
392
+ return cleanedParent ? `${cleanedParent}/*` : "*";
393
+ }
394
+ if (!cleanedParent) {
395
+ return `/${child}`;
396
+ }
397
+ return `${cleanedParent}/${child}`.replace(/\/+/g, "/");
398
+ }
399
+ function getJsxAttributeValue(attributes, name) {
400
+ for (const prop of attributes.properties) {
401
+ if (!ts.isJsxAttribute(prop)) {
402
+ continue;
403
+ }
404
+ if (!ts.isIdentifier(prop.name)) {
405
+ continue;
406
+ }
407
+ if (prop.name.text !== name) {
408
+ continue;
409
+ }
410
+ if (!prop.initializer) {
411
+ return { value: null, present: true };
412
+ }
413
+ if (ts.isStringLiteral(prop.initializer)) {
414
+ return { value: prop.initializer.text, present: true };
415
+ }
416
+ if (ts.isJsxExpression(prop.initializer)) {
417
+ const literal = getStringLiteral(prop.initializer.expression);
418
+ return { value: literal, present: true };
419
+ }
420
+ return { value: null, present: true };
421
+ }
422
+ return { value: null, present: false };
423
+ }
424
+ function getJsxAttributeText(attributes, name) {
425
+ for (const prop of attributes.properties) {
426
+ if (!ts.isJsxAttribute(prop)) {
427
+ continue;
428
+ }
429
+ if (!ts.isIdentifier(prop.name)) {
430
+ continue;
431
+ }
432
+ if (prop.name.text !== name) {
433
+ continue;
434
+ }
435
+ if (!prop.initializer) {
436
+ return null;
437
+ }
438
+ if (ts.isStringLiteral(prop.initializer)) {
439
+ return prop.initializer.text;
440
+ }
441
+ if (ts.isJsxExpression(prop.initializer)) {
442
+ return getExpressionText(prop.initializer.expression);
443
+ }
444
+ return null;
445
+ }
446
+ return null;
447
+ }
448
+ function isLinkTag(tagName) {
449
+ return ts.isIdentifier(tagName) && LINK_TAGS.has(tagName.text);
450
+ }
451
+ function collectRoutesFromObjectLiteral(node, parentPath, routes) {
452
+ let pathValue = null;
453
+ let isIndex = false;
454
+ let children;
455
+ for (const prop of node.properties) {
456
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
457
+ continue;
458
+ }
459
+ const key = prop.name.text;
460
+ if (key === "path") {
461
+ pathValue = getStringLiteral(prop.initializer);
462
+ }
463
+ else if (key === "index") {
464
+ if (prop.initializer && prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
465
+ isIndex = true;
466
+ }
467
+ else if (!prop.initializer) {
468
+ isIndex = true;
469
+ }
470
+ }
471
+ else if (key === "children") {
472
+ children = prop.initializer;
473
+ }
474
+ }
475
+ let currentPath = parentPath;
476
+ if (pathValue) {
477
+ currentPath = joinRoutePaths(parentPath, pathValue);
478
+ routes.add(currentPath);
479
+ }
480
+ else if (isIndex) {
481
+ currentPath = parentPath || "/";
482
+ routes.add(currentPath);
483
+ }
484
+ if (children && ts.isArrayLiteralExpression(children)) {
485
+ for (const element of children.elements) {
486
+ if (ts.isObjectLiteralExpression(element)) {
487
+ collectRoutesFromObjectLiteral(element, currentPath, routes);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ function collectRoutesFromJsx(node, parentPath, routes) {
493
+ if (ts.isJsxElement(node)) {
494
+ const tagName = node.openingElement.tagName;
495
+ if (ts.isIdentifier(tagName) && tagName.text === "Route") {
496
+ const pathAttr = getJsxAttributeValue(node.openingElement.attributes, "path");
497
+ const indexAttr = getJsxAttributeValue(node.openingElement.attributes, "index");
498
+ let currentPath = parentPath;
499
+ if (pathAttr.value) {
500
+ currentPath = joinRoutePaths(parentPath, pathAttr.value);
501
+ routes.add(currentPath);
502
+ }
503
+ else if (indexAttr.present) {
504
+ currentPath = parentPath || "/";
505
+ routes.add(currentPath);
506
+ }
507
+ for (const child of node.children) {
508
+ collectRoutesFromJsx(child, currentPath, routes);
509
+ }
510
+ return;
511
+ }
512
+ }
513
+ if (ts.isJsxSelfClosingElement(node)) {
514
+ const tagName = node.tagName;
515
+ if (ts.isIdentifier(tagName) && tagName.text === "Route") {
516
+ const pathAttr = getJsxAttributeValue(node.attributes, "path");
517
+ const indexAttr = getJsxAttributeValue(node.attributes, "index");
518
+ if (pathAttr.value) {
519
+ routes.add(joinRoutePaths(parentPath, pathAttr.value));
520
+ }
521
+ else if (indexAttr.present) {
522
+ routes.add(parentPath || "/");
523
+ }
524
+ return;
525
+ }
526
+ }
527
+ ts.forEachChild(node, (child) => collectRoutesFromJsx(child, parentPath, routes));
528
+ }
529
+ function isComponentLikeName(name) {
530
+ return name.length > 0 && name[0] === name[0]?.toUpperCase();
531
+ }
532
+ function jsxTagName(tag) {
533
+ if (ts.isIdentifier(tag)) {
534
+ return { full: tag.text, base: tag.text };
535
+ }
536
+ if (ts.isPropertyAccessExpression(tag)) {
537
+ const parts = [];
538
+ let current = tag;
539
+ while (ts.isPropertyAccessExpression(current)) {
540
+ parts.unshift(current.name.text);
541
+ current = current.expression;
542
+ }
543
+ if (ts.isIdentifier(current)) {
544
+ parts.unshift(current.text);
545
+ return { full: parts.join("."), base: current.text };
546
+ }
547
+ }
548
+ return null;
549
+ }
550
+ function isHookCall(expression, hookName) {
551
+ if (ts.isIdentifier(expression)) {
552
+ return expression.text === hookName;
553
+ }
554
+ if (ts.isPropertyAccessExpression(expression)) {
555
+ return expression.name.text === hookName;
556
+ }
557
+ return false;
558
+ }
559
+ function extractNavigationTarget(expression) {
560
+ const literal = getExpressionText(expression);
561
+ if (literal) {
562
+ return literal;
563
+ }
564
+ if (expression && ts.isObjectLiteralExpression(expression)) {
565
+ for (const prop of expression.properties) {
566
+ if (!ts.isPropertyAssignment(prop)) {
567
+ continue;
568
+ }
569
+ const key = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
570
+ if (!key) {
571
+ continue;
572
+ }
573
+ if (key === "pathname" || key === "href" || key === "to") {
574
+ const value = getExpressionText(prop.initializer);
575
+ if (value) {
576
+ return value;
577
+ }
578
+ }
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+ function parseJsFile(content, filePath) {
584
+ const usages = [];
585
+ const exports = new Set();
586
+ const exportDetailMap = new Map();
587
+ const apiCalls = new Map();
588
+ const routePaths = new Set();
589
+ const stateVariables = new Set();
590
+ const navigationTargets = new Set();
591
+ const routerIdentifiers = new Set();
592
+ const routerMethodIdentifiers = new Set();
593
+ const navigateIdentifiers = new Set();
594
+ const jsxTags = new Map();
595
+ const localDeclarations = new Set();
596
+ const objectLiteralBindings = new Map();
597
+ let defaultExportName = null;
598
+ const source = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKindFromPath(filePath));
599
+ const addUsage = (specifier, symbols, wildcard, localNames) => {
600
+ usages.push({ specifier, symbols, wildcard, localNames });
601
+ };
602
+ const addExport = (name) => {
603
+ if (name) {
604
+ exports.add(name);
605
+ }
606
+ };
607
+ const addExportDetail = (detail) => {
608
+ const key = `${detail.kind}|${detail.name}|${detail.alias ?? ""}`;
609
+ exportDetailMap.set(key, detail);
610
+ };
611
+ const handleExportedDeclaration = (node, name) => {
612
+ const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
613
+ const isDefault = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
614
+ if (isDefault) {
615
+ exports.add("default");
616
+ if (name?.text) {
617
+ defaultExportName = name.text;
618
+ }
619
+ addExportDetail({
620
+ name: name?.text ?? "default",
621
+ kind: "default"
622
+ });
623
+ return;
624
+ }
625
+ if (name?.text) {
626
+ exports.add(name.text);
627
+ addExportDetail({
628
+ name: name.text,
629
+ kind: "named"
630
+ });
631
+ }
632
+ };
633
+ const recordStateFromBinding = (binding) => {
634
+ if (!ts.isArrayBindingPattern(binding)) {
635
+ return;
636
+ }
637
+ const first = binding.elements[0];
638
+ if (!first || !ts.isBindingElement(first)) {
639
+ return;
640
+ }
641
+ if (ts.isIdentifier(first.name)) {
642
+ stateVariables.add(first.name.text);
643
+ }
644
+ };
645
+ const recordRouterBinding = (binding) => {
646
+ if (ts.isIdentifier(binding)) {
647
+ routerIdentifiers.add(binding.text);
648
+ return;
649
+ }
650
+ if (!ts.isObjectBindingPattern(binding)) {
651
+ return;
652
+ }
653
+ for (const element of binding.elements) {
654
+ if (!ts.isBindingElement(element)) {
655
+ continue;
656
+ }
657
+ if (!ts.isIdentifier(element.name)) {
658
+ continue;
659
+ }
660
+ const propertyName = element.propertyName;
661
+ const key = propertyName && (ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName))
662
+ ? propertyName.text
663
+ : element.name.text;
664
+ if (NAVIGATION_METHODS.has(key)) {
665
+ routerMethodIdentifiers.add(element.name.text);
666
+ }
667
+ }
668
+ };
669
+ const recordNavigationTarget = (expression) => {
670
+ const target = extractNavigationTarget(expression);
671
+ if (target) {
672
+ navigationTargets.add(target);
673
+ }
674
+ };
675
+ const recordNavigationFromJsx = (attributes) => {
676
+ const href = getJsxAttributeText(attributes, "href");
677
+ const to = getJsxAttributeText(attributes, "to");
678
+ const target = href ?? to;
679
+ if (target) {
680
+ navigationTargets.add(target);
681
+ }
682
+ };
683
+ const addApiCall = (entry) => {
684
+ const key = `${entry.method}|${entry.url}`;
685
+ const current = apiCalls.get(key) ?? {
686
+ method: entry.method,
687
+ url: entry.url,
688
+ requestFields: new Set()
689
+ };
690
+ for (const field of entry.requestFields ?? []) {
691
+ current.requestFields.add(field);
692
+ }
693
+ apiCalls.set(key, current);
694
+ };
695
+ const visit = (node) => {
696
+ if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
697
+ const callee = node.initializer.expression;
698
+ if (isHookCall(callee, "useState") || isHookCall(callee, "useReducer")) {
699
+ recordStateFromBinding(node.name);
700
+ }
701
+ if (isHookCall(callee, "useRouter")) {
702
+ recordRouterBinding(node.name);
703
+ }
704
+ if (isHookCall(callee, "useNavigate")) {
705
+ if (ts.isIdentifier(node.name)) {
706
+ navigateIdentifiers.add(node.name.text);
707
+ }
708
+ }
709
+ }
710
+ else if (ts.isVariableDeclaration(node) &&
711
+ ts.isIdentifier(node.name) &&
712
+ node.initializer) {
713
+ const fields = extractRequestFields(node.initializer, source, objectLiteralBindings);
714
+ if (fields.length > 0) {
715
+ objectLiteralBindings.set(node.name.text, fields);
716
+ }
717
+ }
718
+ if (ts.isImportDeclaration(node)) {
719
+ const specifier = getStringLiteral(node.moduleSpecifier);
720
+ if (specifier) {
721
+ const symbols = [];
722
+ let wildcard = false;
723
+ const localNames = [];
724
+ const clause = node.importClause;
725
+ if (clause) {
726
+ if (clause.name) {
727
+ symbols.push("default");
728
+ localNames.push(clause.name.text);
729
+ }
730
+ if (clause.namedBindings) {
731
+ if (ts.isNamespaceImport(clause.namedBindings)) {
732
+ wildcard = true;
733
+ localNames.push(clause.namedBindings.name.text);
734
+ }
735
+ else if (ts.isNamedImports(clause.namedBindings)) {
736
+ for (const element of clause.namedBindings.elements) {
737
+ const original = element.propertyName?.text ?? element.name.text;
738
+ symbols.push(original);
739
+ localNames.push(element.name.text);
740
+ }
741
+ }
742
+ }
743
+ }
744
+ addUsage(specifier, symbols, wildcard, localNames);
745
+ }
746
+ }
747
+ else if (ts.isImportEqualsDeclaration(node)) {
748
+ if (ts.isExternalModuleReference(node.moduleReference)) {
749
+ const specifier = getStringLiteral(node.moduleReference.expression);
750
+ if (specifier) {
751
+ addUsage(specifier, [], true, []);
752
+ }
753
+ }
754
+ }
755
+ else if (ts.isExportDeclaration(node)) {
756
+ const specifier = node.moduleSpecifier ? getStringLiteral(node.moduleSpecifier) : null;
757
+ if (specifier) {
758
+ if (!node.exportClause) {
759
+ addUsage(specifier, [], true, []);
760
+ }
761
+ else if (ts.isNamedExports(node.exportClause)) {
762
+ const symbols = node.exportClause.elements.map((element) => element.propertyName?.text ?? element.name.text);
763
+ addUsage(specifier, symbols, false, []);
764
+ }
765
+ }
766
+ else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
767
+ for (const element of node.exportClause.elements) {
768
+ exports.add(element.name.text);
769
+ addExportDetail({
770
+ name: element.propertyName?.text ?? element.name.text,
771
+ kind: "named",
772
+ alias: element.propertyName && element.propertyName.text !== element.name.text
773
+ ? element.name.text
774
+ : undefined
775
+ });
776
+ }
777
+ }
778
+ }
779
+ else if (ts.isExportAssignment(node)) {
780
+ exports.add("default");
781
+ if (ts.isIdentifier(node.expression)) {
782
+ defaultExportName = node.expression.text;
783
+ }
784
+ addExportDetail({
785
+ name: ts.isIdentifier(node.expression) ? node.expression.text : "default",
786
+ kind: "default"
787
+ });
788
+ }
789
+ else if (ts.isFunctionDeclaration(node)) {
790
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
791
+ handleExportedDeclaration(node, node.name);
792
+ }
793
+ if (node.name?.text && isComponentLikeName(node.name.text)) {
794
+ localDeclarations.add(node.name.text);
795
+ }
796
+ }
797
+ else if (ts.isClassDeclaration(node)) {
798
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
799
+ handleExportedDeclaration(node, node.name);
800
+ }
801
+ if (node.name?.text && isComponentLikeName(node.name.text)) {
802
+ localDeclarations.add(node.name.text);
803
+ }
804
+ }
805
+ else if (ts.isInterfaceDeclaration(node)) {
806
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
807
+ handleExportedDeclaration(node, node.name);
808
+ }
809
+ }
810
+ else if (ts.isTypeAliasDeclaration(node)) {
811
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
812
+ handleExportedDeclaration(node, node.name);
813
+ }
814
+ }
815
+ else if (ts.isEnumDeclaration(node)) {
816
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
817
+ handleExportedDeclaration(node, node.name);
818
+ }
819
+ }
820
+ else if (ts.isVariableStatement(node)) {
821
+ if (node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
822
+ for (const declaration of node.declarationList.declarations) {
823
+ if (ts.isIdentifier(declaration.name)) {
824
+ exports.add(declaration.name.text);
825
+ addExportDetail({
826
+ name: declaration.name.text,
827
+ kind: "named"
828
+ });
829
+ }
830
+ }
831
+ }
832
+ for (const declaration of node.declarationList.declarations) {
833
+ if (ts.isIdentifier(declaration.name) && isComponentLikeName(declaration.name.text)) {
834
+ localDeclarations.add(declaration.name.text);
835
+ }
836
+ }
837
+ }
838
+ else if (ts.isCallExpression(node)) {
839
+ const apiCall = extractApiCallFromCall(node, source, objectLiteralBindings);
840
+ if (apiCall) {
841
+ addApiCall(apiCall);
842
+ }
843
+ if (ts.isIdentifier(node.expression) && ROUTER_FACTORY_NAMES.has(node.expression.text)) {
844
+ const firstArg = node.arguments[0];
845
+ if (firstArg) {
846
+ if (ts.isArrayLiteralExpression(firstArg)) {
847
+ for (const element of firstArg.elements) {
848
+ if (ts.isObjectLiteralExpression(element)) {
849
+ collectRoutesFromObjectLiteral(element, "", routePaths);
850
+ }
851
+ }
852
+ }
853
+ else if (ts.isCallExpression(firstArg)) {
854
+ if (ts.isIdentifier(firstArg.expression) && firstArg.expression.text === "createRoutesFromElements") {
855
+ const jsxArg = firstArg.arguments[0];
856
+ if (jsxArg) {
857
+ collectRoutesFromJsx(jsxArg, "", routePaths);
858
+ }
859
+ }
860
+ }
861
+ else if (ts.isJsxElement(firstArg) || ts.isJsxSelfClosingElement(firstArg)) {
862
+ collectRoutesFromJsx(firstArg, "", routePaths);
863
+ }
864
+ }
865
+ }
866
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
867
+ const specifier = getStringLiteral(node.arguments[0]);
868
+ if (specifier) {
869
+ addUsage(specifier, [], true, []);
870
+ }
871
+ }
872
+ else if (ts.isIdentifier(node.expression) && node.expression.text === "require") {
873
+ const specifier = getStringLiteral(node.arguments[0]);
874
+ if (specifier) {
875
+ addUsage(specifier, [], true, []);
876
+ }
877
+ }
878
+ else if (ts.isPropertyAccessExpression(node.expression)) {
879
+ const receiver = node.expression.expression;
880
+ const method = node.expression.name.text;
881
+ if (ts.isIdentifier(receiver) && routerIdentifiers.has(receiver.text)) {
882
+ if (NAVIGATION_METHODS.has(method)) {
883
+ recordNavigationTarget(node.arguments[0]);
884
+ }
885
+ }
886
+ }
887
+ else if (ts.isIdentifier(node.expression)) {
888
+ if (navigateIdentifiers.has(node.expression.text) || routerMethodIdentifiers.has(node.expression.text)) {
889
+ recordNavigationTarget(node.arguments[0]);
890
+ }
891
+ }
892
+ }
893
+ if (ts.isJsxElement(node)) {
894
+ const tagInfo = jsxTagName(node.openingElement.tagName);
895
+ if (tagInfo && isComponentLikeName(tagInfo.base)) {
896
+ jsxTags.set(tagInfo.full, tagInfo);
897
+ }
898
+ if (isLinkTag(node.openingElement.tagName)) {
899
+ recordNavigationFromJsx(node.openingElement.attributes);
900
+ }
901
+ }
902
+ else if (ts.isJsxSelfClosingElement(node)) {
903
+ const tagInfo = jsxTagName(node.tagName);
904
+ if (tagInfo && isComponentLikeName(tagInfo.base)) {
905
+ jsxTags.set(tagInfo.full, tagInfo);
906
+ }
907
+ if (isLinkTag(node.tagName)) {
908
+ recordNavigationFromJsx(node.attributes);
909
+ }
910
+ }
911
+ ts.forEachChild(node, visit);
912
+ };
913
+ visit(source);
914
+ collectRoutesFromJsx(source, "", routePaths);
915
+ return {
916
+ usages,
917
+ exports: Array.from(exports).sort((a, b) => a.localeCompare(b)),
918
+ exportDetails: Array.from(exportDetailMap.values()).sort((a, b) => {
919
+ const kind = a.kind.localeCompare(b.kind);
920
+ if (kind !== 0) {
921
+ return kind;
922
+ }
923
+ const name = a.name.localeCompare(b.name);
924
+ if (name !== 0) {
925
+ return name;
926
+ }
927
+ return (a.alias ?? "").localeCompare(b.alias ?? "");
928
+ }),
929
+ apiCalls: Array.from(apiCalls.values())
930
+ .map((entry) => ({
931
+ method: entry.method,
932
+ url: entry.url,
933
+ requestFields: Array.from(entry.requestFields).sort((a, b) => a.localeCompare(b))
934
+ }))
935
+ .sort((a, b) => {
936
+ const method = a.method.localeCompare(b.method);
937
+ if (method !== 0) {
938
+ return method;
939
+ }
940
+ return a.url.localeCompare(b.url);
941
+ }),
942
+ routes: Array.from(routePaths).sort((a, b) => a.localeCompare(b)),
943
+ stateVariables: Array.from(stateVariables).sort((a, b) => a.localeCompare(b)),
944
+ navigationTargets: Array.from(navigationTargets).sort((a, b) => a.localeCompare(b)),
945
+ jsxTags: Array.from(jsxTags.values()),
946
+ localDeclarations: Array.from(localDeclarations).sort((a, b) => a.localeCompare(b)),
947
+ defaultExportName
948
+ };
949
+ }
950
+ function isPageFile(filePath) {
951
+ const ext = path.extname(filePath);
952
+ if (!PAGE_EXTENSIONS.has(ext)) {
953
+ return false;
954
+ }
955
+ return path.basename(filePath, ext) === "page";
956
+ }
957
+ function isRouteFile(filePath) {
958
+ const ext = path.extname(filePath);
959
+ if (!PAGE_EXTENSIONS.has(ext)) {
960
+ return false;
961
+ }
962
+ const base = path.basename(filePath, ext);
963
+ return ROUTE_FILE_BASENAMES.has(base);
964
+ }
965
+ async function resolveComponentRoots(root) {
966
+ const candidates = [
967
+ path.join(root, "components"),
968
+ path.join(root, "app"),
969
+ path.join(root, "src", "components"),
970
+ path.join(root, "src", "app")
971
+ ];
972
+ const resolved = [];
973
+ for (const candidate of candidates) {
974
+ if (await existsDir(candidate)) {
975
+ resolved.push(candidate);
976
+ }
977
+ }
978
+ return resolved;
979
+ }
980
+ function isUtilityComponent(name, origin) {
981
+ if (UTILITY_NAME_PATTERNS.some((pattern) => pattern.test(name))) {
982
+ return true;
983
+ }
984
+ const segments = origin.split(path.sep).filter(Boolean).map((segment) => segment.toLowerCase());
985
+ return segments.some((segment) => UTILITY_PATH_SEGMENTS.has(segment));
986
+ }
987
+ function componentId(file, name) {
988
+ return `${file}#${name}`;
989
+ }
990
+ function isUnknownType(type) {
991
+ return ((type.flags & ts.TypeFlags.Any) !== 0 ||
992
+ (type.flags & ts.TypeFlags.Unknown) !== 0);
993
+ }
994
+ function propsFromType(type, checker, location) {
995
+ const props = [];
996
+ for (const symbol of type.getProperties()) {
997
+ const name = symbol.getName();
998
+ if (name.startsWith("__")) {
999
+ continue;
1000
+ }
1001
+ const propType = checker.getTypeOfSymbolAtLocation(symbol, location);
1002
+ const typeText = checker.typeToString(propType) || "unknown";
1003
+ const optional = (symbol.getFlags() & ts.SymbolFlags.Optional) !== 0;
1004
+ props.push({ name, type: typeText, optional });
1005
+ }
1006
+ return props;
1007
+ }
1008
+ function propsFromBindingPattern(pattern) {
1009
+ const props = [];
1010
+ for (const element of pattern.elements) {
1011
+ if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
1012
+ const name = element.name.text;
1013
+ props.push({
1014
+ name,
1015
+ type: "unknown",
1016
+ optional: Boolean(element.initializer)
1017
+ });
1018
+ }
1019
+ }
1020
+ return props;
1021
+ }
1022
+ function extractPropsFromTypeNode(typeNode, checker, location) {
1023
+ if (!typeNode) {
1024
+ return [];
1025
+ }
1026
+ if (ts.isTypeReferenceNode(typeNode) && typeNode.typeArguments?.length) {
1027
+ const argType = checker.getTypeFromTypeNode(typeNode.typeArguments[0]);
1028
+ return propsFromType(argType, checker, location);
1029
+ }
1030
+ const type = checker.getTypeFromTypeNode(typeNode);
1031
+ return propsFromType(type, checker, location);
1032
+ }
1033
+ function extractPropsFromFunctionLike(node, checker) {
1034
+ const firstParam = node.parameters[0];
1035
+ if (!firstParam) {
1036
+ return [];
1037
+ }
1038
+ if (firstParam.type) {
1039
+ const props = extractPropsFromTypeNode(firstParam.type, checker, firstParam);
1040
+ if (props.length > 0) {
1041
+ return props;
1042
+ }
1043
+ }
1044
+ const paramType = checker.getTypeAtLocation(firstParam);
1045
+ if (!isUnknownType(paramType)) {
1046
+ const props = propsFromType(paramType, checker, firstParam);
1047
+ if (props.length > 0) {
1048
+ return props;
1049
+ }
1050
+ }
1051
+ if (ts.isObjectBindingPattern(firstParam.name)) {
1052
+ return propsFromBindingPattern(firstParam.name);
1053
+ }
1054
+ return [];
1055
+ }
1056
+ function extractPropsFromClass(node, checker) {
1057
+ if (!node.heritageClauses) {
1058
+ return [];
1059
+ }
1060
+ for (const clause of node.heritageClauses) {
1061
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) {
1062
+ continue;
1063
+ }
1064
+ for (const heritageType of clause.types) {
1065
+ if (!heritageType.typeArguments || heritageType.typeArguments.length === 0) {
1066
+ continue;
1067
+ }
1068
+ const propsType = checker.getTypeFromTypeNode(heritageType.typeArguments[0]);
1069
+ const props = propsFromType(propsType, checker, heritageType);
1070
+ if (props.length > 0) {
1071
+ return props;
1072
+ }
1073
+ }
1074
+ }
1075
+ return [];
1076
+ }
1077
+ function isRouteGroup(segment) {
1078
+ return segment.startsWith("(") && segment.endsWith(")");
1079
+ }
1080
+ function routeFromFile(appDir, filePath) {
1081
+ const relativeDir = path.relative(appDir, path.dirname(filePath));
1082
+ if (!relativeDir) {
1083
+ return "/";
1084
+ }
1085
+ const segments = relativeDir
1086
+ .split(path.sep)
1087
+ .filter(Boolean)
1088
+ .filter((segment) => !isRouteGroup(segment));
1089
+ if (segments.length === 0) {
1090
+ return "/";
1091
+ }
1092
+ return `/${segments.join("/")}`;
1093
+ }
1094
+ function componentFromRoute(route) {
1095
+ if (route === "/") {
1096
+ return "HomePage";
1097
+ }
1098
+ const segments = route.split("/").filter(Boolean);
1099
+ const last = segments[segments.length - 1] ?? "page";
1100
+ let cleaned = last.replace(/[\[\]]/g, "");
1101
+ cleaned = cleaned.replace(/^\.\.\./, "");
1102
+ cleaned = cleaned.replace(/^\.+/, "");
1103
+ const words = cleaned.split(/[^a-zA-Z0-9]+/).filter(Boolean);
1104
+ const pascal = words.map((word) => word[0]?.toUpperCase() + word.slice(1)).join("");
1105
+ return `${pascal || "Page"}Page`;
1106
+ }
1107
+ function componentNameFromFile(filePath) {
1108
+ const ext = path.extname(filePath);
1109
+ const base = path.basename(filePath, ext);
1110
+ if (base.toLowerCase() === "index") {
1111
+ const dir = path.basename(path.dirname(filePath));
1112
+ return `${dir.charAt(0).toUpperCase()}${dir.slice(1)}`;
1113
+ }
1114
+ const words = base.split(/[^a-zA-Z0-9]+/).filter(Boolean);
1115
+ const pascal = words.map((word) => word[0]?.toUpperCase() + word.slice(1)).join("");
1116
+ return pascal || "Component";
1117
+ }
1118
+ async function resolveRouteDirs(root, routeDirs) {
1119
+ const resolved = [];
1120
+ for (const entry of routeDirs) {
1121
+ const candidate = path.isAbsolute(entry) ? entry : path.join(root, entry);
1122
+ if (await existsDir(candidate)) {
1123
+ resolved.push(candidate);
1124
+ }
1125
+ }
1126
+ return resolved;
1127
+ }
1128
+ function normalizeRoutePath(route) {
1129
+ if (!route) {
1130
+ return "/";
1131
+ }
1132
+ if (route === "*" || route.startsWith("/")) {
1133
+ return route;
1134
+ }
1135
+ return `/${route}`;
1136
+ }
1137
+ function isSubPath(parent, child) {
1138
+ const rel = path.relative(parent, child);
1139
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
1140
+ }
1141
+ function extractFetchCalls(content) {
1142
+ const calls = [];
1143
+ const fetchPattern = /\bfetch\s*\(\s*(["'`])([^"'`]+)\1/g;
1144
+ let match;
1145
+ while ((match = fetchPattern.exec(content))) {
1146
+ const url = match[2];
1147
+ const snippet = content.slice(match.index, match.index + 300);
1148
+ const methodMatch = snippet.match(/method\s*:\s*["'`]([A-Za-z]+)["'`]/i);
1149
+ const method = methodMatch ? methodMatch[1].toUpperCase() : "GET";
1150
+ calls.push({ method, url });
1151
+ }
1152
+ return calls;
1153
+ }
1154
+ function extractAxiosCalls(content) {
1155
+ const calls = [];
1156
+ const axiosMethodPattern = /\baxios\.(get|post|put|patch|delete|head|options)\s*(?:<[^>]+>)?\s*\(\s*(["'`])([^"'`]+)\2/g;
1157
+ let match;
1158
+ while ((match = axiosMethodPattern.exec(content))) {
1159
+ calls.push({ method: match[1].toUpperCase(), url: match[3] });
1160
+ }
1161
+ const axiosConfigPattern = /\baxios\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
1162
+ while ((match = axiosConfigPattern.exec(content))) {
1163
+ const snippet = match[1];
1164
+ const methodMatch = snippet.match(/method\s*:\s*["'`]([A-Za-z]+)["'`]/i);
1165
+ const urlMatch = snippet.match(/url\s*:\s*["'`]([^"'`]+)["'`]/i);
1166
+ if (urlMatch) {
1167
+ const method = methodMatch ? methodMatch[1].toUpperCase() : "GET";
1168
+ calls.push({ method, url: urlMatch[1] });
1169
+ }
1170
+ }
1171
+ return calls;
1172
+ }
1173
+ function extractApiClientCalls(content) {
1174
+ const calls = [];
1175
+ const clientPattern = /\b([A-Za-z_][A-Za-z0-9_]*)\.(get|post|put|patch|delete|head|options|fetch)\s*(?:<[^>]+>)?\s*\(\s*(["'`])([^"'`]+)\3/g;
1176
+ let match;
1177
+ while ((match = clientPattern.exec(content))) {
1178
+ const client = match[1];
1179
+ if (client !== "api" && !client.endsWith("Api") && !client.endsWith("API")) {
1180
+ continue;
1181
+ }
1182
+ let method = match[2].toUpperCase();
1183
+ if (method === "FETCH") {
1184
+ method = "GET";
1185
+ }
1186
+ calls.push({ method, url: match[4] });
1187
+ }
1188
+ return calls;
1189
+ }
1190
+ function normalizeApiCalls(calls) {
1191
+ const seen = new Set();
1192
+ const result = [];
1193
+ for (const call of calls) {
1194
+ const key = `${call.method}|${call.url}`;
1195
+ if (seen.has(key)) {
1196
+ continue;
1197
+ }
1198
+ seen.add(key);
1199
+ result.push(call);
1200
+ }
1201
+ return result;
1202
+ }
1203
+ function getObjectLiteralString(node, key, sourceFile) {
1204
+ if (!node || !ts.isObjectLiteralExpression(node)) {
1205
+ return null;
1206
+ }
1207
+ for (const prop of node.properties) {
1208
+ if (!ts.isPropertyAssignment(prop)) {
1209
+ continue;
1210
+ }
1211
+ const name = ts.isIdentifier(prop.name)
1212
+ ? prop.name.text
1213
+ : ts.isStringLiteral(prop.name)
1214
+ ? prop.name.text
1215
+ : null;
1216
+ if (name !== key) {
1217
+ continue;
1218
+ }
1219
+ if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
1220
+ return prop.initializer.text;
1221
+ }
1222
+ if (ts.isTemplateExpression(prop.initializer)) {
1223
+ return prop.initializer.getText(sourceFile);
1224
+ }
1225
+ }
1226
+ return null;
1227
+ }
1228
+ function extractObjectLiteralFieldsFromNode(node) {
1229
+ const fields = new Set();
1230
+ for (const prop of node.properties) {
1231
+ if (ts.isShorthandPropertyAssignment(prop)) {
1232
+ fields.add(prop.name.text);
1233
+ continue;
1234
+ }
1235
+ if (ts.isPropertyAssignment(prop) || ts.isMethodDeclaration(prop)) {
1236
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
1237
+ ? prop.name.text
1238
+ : ts.isComputedPropertyName(prop.name)
1239
+ ? prop.name.expression.getText()
1240
+ : null;
1241
+ if (name) {
1242
+ fields.add(name);
1243
+ }
1244
+ }
1245
+ }
1246
+ return Array.from(fields).sort((a, b) => a.localeCompare(b));
1247
+ }
1248
+ function extractRequestFields(node, sourceFile, objectLiteralBindings) {
1249
+ if (!node) {
1250
+ return [];
1251
+ }
1252
+ if (ts.isObjectLiteralExpression(node)) {
1253
+ return extractObjectLiteralFieldsFromNode(node);
1254
+ }
1255
+ if (ts.isIdentifier(node)) {
1256
+ return objectLiteralBindings.get(node.text) ?? [];
1257
+ }
1258
+ if (ts.isCallExpression(node) &&
1259
+ ts.isPropertyAccessExpression(node.expression) &&
1260
+ ts.isIdentifier(node.expression.expression) &&
1261
+ node.expression.expression.text === "JSON" &&
1262
+ node.expression.name.text === "stringify") {
1263
+ return extractRequestFields(node.arguments[0], sourceFile, objectLiteralBindings);
1264
+ }
1265
+ if (ts.isParenthesizedExpression(node)) {
1266
+ return extractRequestFields(node.expression, sourceFile, objectLiteralBindings);
1267
+ }
1268
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
1269
+ return extractRequestFields(node.expression, sourceFile, objectLiteralBindings);
1270
+ }
1271
+ return [];
1272
+ }
1273
+ function extractApiCallFromCall(node, sourceFile, objectLiteralBindings) {
1274
+ const expression = node.expression;
1275
+ if (ts.isIdentifier(expression) && expression.text === "fetch") {
1276
+ const url = getExpressionText(node.arguments[0]);
1277
+ if (!url) {
1278
+ return null;
1279
+ }
1280
+ const method = getObjectLiteralString(node.arguments[1], "method", sourceFile)?.toUpperCase() ?? "GET";
1281
+ let requestFields = [];
1282
+ const optionsArg = node.arguments[1];
1283
+ if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
1284
+ for (const prop of optionsArg.properties) {
1285
+ if (ts.isPropertyAssignment(prop) &&
1286
+ (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
1287
+ prop.name.text === "body") {
1288
+ requestFields = extractRequestFields(prop.initializer, sourceFile, objectLiteralBindings);
1289
+ }
1290
+ }
1291
+ }
1292
+ return { method, url, requestFields };
1293
+ }
1294
+ if (ts.isIdentifier(expression) && expression.text === "axios") {
1295
+ const configArg = node.arguments[0];
1296
+ const url = getObjectLiteralString(configArg, "url", sourceFile);
1297
+ if (!url) {
1298
+ return null;
1299
+ }
1300
+ const method = getObjectLiteralString(configArg, "method", sourceFile)?.toUpperCase() ?? "GET";
1301
+ let requestFields = [];
1302
+ if (configArg && ts.isObjectLiteralExpression(configArg)) {
1303
+ for (const prop of configArg.properties) {
1304
+ if (ts.isPropertyAssignment(prop) &&
1305
+ (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
1306
+ prop.name.text === "data") {
1307
+ requestFields = extractRequestFields(prop.initializer, sourceFile, objectLiteralBindings);
1308
+ }
1309
+ }
1310
+ }
1311
+ return { method, url, requestFields };
1312
+ }
1313
+ if (ts.isPropertyAccessExpression(expression)) {
1314
+ const method = expression.name.text.toLowerCase();
1315
+ if (!HTTP_METHODS.has(method) && method !== "fetch") {
1316
+ return null;
1317
+ }
1318
+ const receiver = expression.expression;
1319
+ let receiverName = null;
1320
+ if (ts.isIdentifier(receiver)) {
1321
+ receiverName = receiver.text;
1322
+ }
1323
+ else if (ts.isPropertyAccessExpression(receiver)) {
1324
+ receiverName = receiver.name.text;
1325
+ }
1326
+ if (!receiverName) {
1327
+ return null;
1328
+ }
1329
+ if (receiverName !== "axios" && receiverName !== "api" && !receiverName.endsWith("Api") && !receiverName.endsWith("API")) {
1330
+ return null;
1331
+ }
1332
+ const url = getExpressionText(node.arguments[0]);
1333
+ if (!url) {
1334
+ return null;
1335
+ }
1336
+ const normalizedMethod = method === "fetch" ? "GET" : method.toUpperCase();
1337
+ const requestFields = method === "get" || method === "delete" || method === "fetch"
1338
+ ? []
1339
+ : extractRequestFields(node.arguments[1], sourceFile, objectLiteralBindings);
1340
+ return { method: normalizedMethod, url, requestFields };
1341
+ }
1342
+ return null;
1343
+ }
1344
+ function collectResolvedApiCalls(params) {
1345
+ const direct = extractApiCallFromCall(params.node, params.sourceFile, params.objectLiteralBindings);
1346
+ if (direct) {
1347
+ return [direct];
1348
+ }
1349
+ const depth = params.depth ?? 0;
1350
+ if (depth >= 2) {
1351
+ return [];
1352
+ }
1353
+ const symbol = resolveCallSymbol(params.node.expression, params.checker);
1354
+ if (!symbol) {
1355
+ return [];
1356
+ }
1357
+ const visitedSymbols = params.visitedSymbols ?? new Set();
1358
+ const symbolKey = `${symbol.getName()}::${symbol.declarations?.[0]?.getSourceFile().fileName ?? ""}`;
1359
+ if (visitedSymbols.has(symbolKey)) {
1360
+ return [];
1361
+ }
1362
+ visitedSymbols.add(symbolKey);
1363
+ const results = [];
1364
+ for (const declaration of symbol.getDeclarations() ?? []) {
1365
+ const fileName = declaration.getSourceFile().fileName;
1366
+ if (fileName.includes("node_modules")) {
1367
+ continue;
1368
+ }
1369
+ const body = getCallableBody(declaration);
1370
+ if (!body) {
1371
+ continue;
1372
+ }
1373
+ const localBindings = new Map();
1374
+ ts.forEachChild(body, function visit(child) {
1375
+ if (ts.isVariableDeclaration(child) &&
1376
+ ts.isIdentifier(child.name) &&
1377
+ child.initializer) {
1378
+ const fields = extractRequestFields(child.initializer, declaration.getSourceFile(), localBindings);
1379
+ if (fields.length > 0) {
1380
+ localBindings.set(child.name.text, fields);
1381
+ }
1382
+ }
1383
+ if (ts.isCallExpression(child)) {
1384
+ const nested = collectResolvedApiCalls({
1385
+ node: child,
1386
+ sourceFile: declaration.getSourceFile(),
1387
+ checker: params.checker,
1388
+ objectLiteralBindings: localBindings,
1389
+ visitedSymbols,
1390
+ depth: depth + 1
1391
+ });
1392
+ results.push(...nested);
1393
+ }
1394
+ ts.forEachChild(child, visit);
1395
+ });
1396
+ }
1397
+ return dedupeResolvedApiCalls(results);
1398
+ }
1399
+ function resolveCallSymbol(expression, checker) {
1400
+ let symbol;
1401
+ if (ts.isPropertyAccessExpression(expression)) {
1402
+ symbol = checker.getSymbolAtLocation(expression.name);
1403
+ }
1404
+ else if (ts.isIdentifier(expression)) {
1405
+ symbol = checker.getSymbolAtLocation(expression);
1406
+ }
1407
+ if (!symbol) {
1408
+ return null;
1409
+ }
1410
+ return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
1411
+ }
1412
+ function getCallableBody(declaration) {
1413
+ if (ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) {
1414
+ return declaration.body ?? null;
1415
+ }
1416
+ if (ts.isFunctionExpression(declaration) || ts.isArrowFunction(declaration)) {
1417
+ return declaration.body;
1418
+ }
1419
+ if (ts.isPropertyAssignment(declaration) &&
1420
+ (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
1421
+ return declaration.initializer.body;
1422
+ }
1423
+ if (ts.isVariableDeclaration(declaration) &&
1424
+ declaration.initializer &&
1425
+ (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
1426
+ return declaration.initializer.body;
1427
+ }
1428
+ return null;
1429
+ }
1430
+ function dedupeResolvedApiCalls(calls) {
1431
+ const byKey = new Map();
1432
+ for (const call of calls) {
1433
+ const key = `${call.method}|${call.url}`;
1434
+ const entry = byKey.get(key) ?? {
1435
+ method: call.method,
1436
+ url: call.url,
1437
+ requestFields: new Set()
1438
+ };
1439
+ for (const field of call.requestFields) {
1440
+ entry.requestFields.add(field);
1441
+ }
1442
+ byKey.set(key, entry);
1443
+ }
1444
+ return Array.from(byKey.values()).map((entry) => ({
1445
+ method: entry.method,
1446
+ url: entry.url,
1447
+ requestFields: Array.from(entry.requestFields).sort((a, b) => a.localeCompare(b))
1448
+ }));
1449
+ }
1450
+ function getDefaultExportName(sourceFile, checker) {
1451
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
1452
+ if (!moduleSymbol) {
1453
+ return null;
1454
+ }
1455
+ const exports = checker.getExportsOfModule(moduleSymbol);
1456
+ const defaultExport = exports.find((symbol) => symbol.escapedName === "default");
1457
+ if (!defaultExport) {
1458
+ return null;
1459
+ }
1460
+ const declarations = defaultExport.getDeclarations() ?? [];
1461
+ for (const decl of declarations) {
1462
+ if (ts.isFunctionDeclaration(decl) || ts.isClassDeclaration(decl)) {
1463
+ if (decl.name?.text) {
1464
+ return decl.name.text;
1465
+ }
1466
+ }
1467
+ if (ts.isVariableDeclaration(decl) && ts.isIdentifier(decl.name)) {
1468
+ return decl.name.text;
1469
+ }
1470
+ }
1471
+ return null;
1472
+ }
1473
+ function isComponentFunction(node) {
1474
+ return containsJsx(node);
1475
+ }
1476
+ function isComponentClass(node) {
1477
+ if (!node.name?.text || !isPascalCase(node.name.text)) {
1478
+ return false;
1479
+ }
1480
+ if (node.heritageClauses) {
1481
+ for (const clause of node.heritageClauses) {
1482
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) {
1483
+ continue;
1484
+ }
1485
+ for (const type of clause.types) {
1486
+ const name = type.expression.getText();
1487
+ if (name.includes("Component") || name.includes("PureComponent")) {
1488
+ return true;
1489
+ }
1490
+ }
1491
+ }
1492
+ }
1493
+ for (const member of node.members) {
1494
+ if (ts.isMethodDeclaration(member) && member.name.getText() === "render" && member.body) {
1495
+ if (containsJsx(member.body)) {
1496
+ return true;
1497
+ }
1498
+ }
1499
+ }
1500
+ return false;
1501
+ }
1502
+ function resolveComponentFromSymbol(symbol, checker, root, componentRoots) {
1503
+ if (!symbol) {
1504
+ return null;
1505
+ }
1506
+ const resolved = (symbol.flags & ts.SymbolFlags.Alias) ? checker.getAliasedSymbol(symbol) : symbol;
1507
+ const declarations = resolved.getDeclarations() ?? [];
1508
+ for (const decl of declarations) {
1509
+ const sourceFile = decl.getSourceFile();
1510
+ const fileName = sourceFile.fileName;
1511
+ if (fileName.includes("node_modules")) {
1512
+ continue;
1513
+ }
1514
+ const rel = toPosix(path.relative(root, fileName));
1515
+ const abs = path.resolve(fileName);
1516
+ if (!componentRoots.some((dir) => isSubPath(dir, abs))) {
1517
+ continue;
1518
+ }
1519
+ const name = (ts.isFunctionDeclaration(decl) || ts.isClassDeclaration(decl)) && decl.name?.text
1520
+ ? decl.name.text
1521
+ : ts.isVariableDeclaration(decl) && ts.isIdentifier(decl.name)
1522
+ ? decl.name.text
1523
+ : resolved.getName() === "default"
1524
+ ? componentNameFromFile(fileName)
1525
+ : resolved.getName();
1526
+ const id = componentId(rel, name);
1527
+ return { id, name, file: rel };
1528
+ }
1529
+ return null;
1530
+ }
1531
+ function resolveComponentExportKind(params) {
1532
+ const { componentName, file, kind, fileExportDetails, defaultExportNames } = params;
1533
+ if (kind === "page") {
1534
+ return "default";
1535
+ }
1536
+ const exportDetails = fileExportDetails.get(file) ?? [];
1537
+ for (const detail of exportDetails) {
1538
+ const publicName = detail.alias ?? detail.name;
1539
+ if (detail.kind === "default") {
1540
+ if (detail.name === componentName ||
1541
+ publicName === componentName ||
1542
+ publicName === "default") {
1543
+ return "default";
1544
+ }
1545
+ continue;
1546
+ }
1547
+ if (detail.name === componentName || publicName === componentName) {
1548
+ return "named";
1549
+ }
1550
+ }
1551
+ if (defaultExportNames.get(file) === componentName) {
1552
+ return "default";
1553
+ }
1554
+ if (exportDetails.length === 1 && exportDetails[0]?.kind === "default") {
1555
+ return "default";
1556
+ }
1557
+ return "named";
1558
+ }
1559
+ async function analyzeComponentsAst(params) {
1560
+ const { root, files, pageFileToRoute, routePageFile, pageMap, componentRoots, fileExportDetails, defaultExportNames, config } = params;
1561
+ const compilerOptions = await loadTsCompilerOptions(root, config.frontend?.tsconfigPath);
1562
+ const program = ts.createProgram({ rootNames: files, options: compilerOptions });
1563
+ const checker = program.getTypeChecker();
1564
+ const componentNodes = new Map();
1565
+ const componentEdges = new Set();
1566
+ const componentApiCalls = new Map();
1567
+ const componentStateVariables = new Map();
1568
+ const componentNavigation = new Map();
1569
+ const pageComponentIds = new Map();
1570
+ const registerComponent = (id, name, file, kind, props) => {
1571
+ const exportKind = resolveComponentExportKind({
1572
+ componentName: name,
1573
+ file,
1574
+ kind,
1575
+ fileExportDetails,
1576
+ defaultExportNames
1577
+ });
1578
+ const existing = componentNodes.get(id);
1579
+ if (!existing) {
1580
+ componentNodes.set(id, {
1581
+ id,
1582
+ name,
1583
+ file,
1584
+ kind,
1585
+ export_kind: exportKind,
1586
+ props: props && props.length > 0 ? props : undefined
1587
+ });
1588
+ }
1589
+ else if (existing.kind !== "page" && kind === "page") {
1590
+ componentNodes.set(id, {
1591
+ ...existing,
1592
+ kind: "page",
1593
+ export_kind: "default",
1594
+ props: props && props.length > 0 ? props : existing.props
1595
+ });
1596
+ }
1597
+ else if (existing.export_kind !== "default" && exportKind === "default") {
1598
+ componentNodes.set(id, {
1599
+ ...existing,
1600
+ export_kind: exportKind,
1601
+ props: props && props.length > 0 ? props : existing.props
1602
+ });
1603
+ }
1604
+ else if (props && props.length > 0 && (!existing.props || existing.props.length === 0)) {
1605
+ componentNodes.set(id, { ...existing, props });
1606
+ }
1607
+ };
1608
+ const addSignal = (map, id, values) => {
1609
+ if (values.length === 0) {
1610
+ return;
1611
+ }
1612
+ const entry = map.get(id) ?? new Set();
1613
+ for (const value of values) {
1614
+ entry.add(value);
1615
+ }
1616
+ map.set(id, entry);
1617
+ };
1618
+ for (const filePath of files) {
1619
+ const sourceFile = program.getSourceFile(filePath);
1620
+ if (!sourceFile) {
1621
+ continue;
1622
+ }
1623
+ const relative = toPosix(path.relative(root, filePath));
1624
+ const route = pageFileToRoute.get(relative);
1625
+ const defaultExportName = getDefaultExportName(sourceFile, checker);
1626
+ if (route && defaultExportName) {
1627
+ const summary = pageMap.get(route);
1628
+ if (summary) {
1629
+ summary.component = defaultExportName;
1630
+ }
1631
+ }
1632
+ const componentStack = [];
1633
+ const routerIdentifiers = new Map();
1634
+ const routerMethodIdentifiers = new Map();
1635
+ const navigateIdentifiers = new Map();
1636
+ const objectLiteralBindings = new Map();
1637
+ const registerRouterBinding = (componentId, binding) => {
1638
+ if (ts.isIdentifier(binding)) {
1639
+ const set = routerIdentifiers.get(componentId) ?? new Set();
1640
+ set.add(binding.text);
1641
+ routerIdentifiers.set(componentId, set);
1642
+ return;
1643
+ }
1644
+ if (ts.isObjectBindingPattern(binding)) {
1645
+ for (const element of binding.elements) {
1646
+ if (!ts.isBindingElement(element) || !ts.isIdentifier(element.name)) {
1647
+ continue;
1648
+ }
1649
+ const propertyName = element.propertyName;
1650
+ const key = propertyName && (ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName))
1651
+ ? propertyName.text
1652
+ : element.name.text;
1653
+ if (NAVIGATION_METHODS.has(key)) {
1654
+ const set = routerMethodIdentifiers.get(componentId) ?? new Set();
1655
+ set.add(element.name.text);
1656
+ routerMethodIdentifiers.set(componentId, set);
1657
+ }
1658
+ }
1659
+ }
1660
+ };
1661
+ const registerNavigateBinding = (componentId, binding) => {
1662
+ if (ts.isIdentifier(binding)) {
1663
+ const set = navigateIdentifiers.get(componentId) ?? new Set();
1664
+ set.add(binding.text);
1665
+ navigateIdentifiers.set(componentId, set);
1666
+ }
1667
+ };
1668
+ const visit = (node) => {
1669
+ if (ts.isFunctionDeclaration(node) && node.name?.text && isPascalCase(node.name.text)) {
1670
+ if (isComponentFunction(node)) {
1671
+ const id = componentId(relative, node.name.text);
1672
+ const props = extractPropsFromFunctionLike(node, checker);
1673
+ registerComponent(id, node.name.text, relative, "component", props);
1674
+ componentStack.push(id);
1675
+ if (node.body) {
1676
+ ts.forEachChild(node.body, visit);
1677
+ }
1678
+ componentStack.pop();
1679
+ return;
1680
+ }
1681
+ }
1682
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isPascalCase(node.name.text)) {
1683
+ const initializer = node.initializer;
1684
+ if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
1685
+ if (isComponentFunction(initializer)) {
1686
+ const id = componentId(relative, node.name.text);
1687
+ const typeProps = extractPropsFromTypeNode(node.type, checker, node);
1688
+ const paramProps = extractPropsFromFunctionLike(initializer, checker);
1689
+ const props = typeProps.length > 0 ? typeProps : paramProps;
1690
+ registerComponent(id, node.name.text, relative, "component", props);
1691
+ componentStack.push(id);
1692
+ if (initializer.body) {
1693
+ ts.forEachChild(initializer.body, visit);
1694
+ }
1695
+ componentStack.pop();
1696
+ return;
1697
+ }
1698
+ }
1699
+ }
1700
+ if (ts.isClassDeclaration(node) && node.name?.text && isComponentClass(node)) {
1701
+ const id = componentId(relative, node.name.text);
1702
+ const props = extractPropsFromClass(node, checker);
1703
+ registerComponent(id, node.name.text, relative, "component", props);
1704
+ componentStack.push(id);
1705
+ ts.forEachChild(node, visit);
1706
+ componentStack.pop();
1707
+ return;
1708
+ }
1709
+ const currentComponentId = componentStack[componentStack.length - 1];
1710
+ if (currentComponentId) {
1711
+ if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
1712
+ const callee = node.initializer.expression;
1713
+ if (isHookCall(callee, "useState") || isHookCall(callee, "useReducer")) {
1714
+ if (ts.isArrayBindingPattern(node.name) && node.name.elements.length > 0) {
1715
+ const first = node.name.elements[0];
1716
+ if (first && ts.isBindingElement(first) && ts.isIdentifier(first.name)) {
1717
+ addSignal(componentStateVariables, currentComponentId, [first.name.text]);
1718
+ }
1719
+ }
1720
+ }
1721
+ if (isHookCall(callee, "useRouter")) {
1722
+ registerRouterBinding(currentComponentId, node.name);
1723
+ }
1724
+ if (isHookCall(callee, "useNavigate")) {
1725
+ registerNavigateBinding(currentComponentId, node.name);
1726
+ }
1727
+ }
1728
+ else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
1729
+ const fields = extractRequestFields(node.initializer, sourceFile, objectLiteralBindings);
1730
+ if (fields.length > 0) {
1731
+ objectLiteralBindings.set(node.name.text, fields);
1732
+ }
1733
+ }
1734
+ if (ts.isCallExpression(node)) {
1735
+ const apiCalls = collectResolvedApiCalls({
1736
+ node,
1737
+ sourceFile,
1738
+ checker,
1739
+ objectLiteralBindings
1740
+ });
1741
+ for (const apiCall of apiCalls) {
1742
+ addSignal(componentApiCalls, currentComponentId, [
1743
+ `${apiCall.method} ${apiCall.url}`
1744
+ ]);
1745
+ }
1746
+ if (ts.isPropertyAccessExpression(node.expression)) {
1747
+ const receiver = node.expression.expression;
1748
+ const method = node.expression.name.text;
1749
+ if (ts.isIdentifier(receiver)) {
1750
+ const routers = routerIdentifiers.get(currentComponentId) ?? new Set();
1751
+ if (routers.has(receiver.text) && NAVIGATION_METHODS.has(method)) {
1752
+ const target = extractNavigationTarget(node.arguments[0]);
1753
+ if (target) {
1754
+ addSignal(componentNavigation, currentComponentId, [target]);
1755
+ }
1756
+ }
1757
+ }
1758
+ }
1759
+ else if (ts.isIdentifier(node.expression)) {
1760
+ const navigate = navigateIdentifiers.get(currentComponentId) ?? new Set();
1761
+ const methods = routerMethodIdentifiers.get(currentComponentId) ?? new Set();
1762
+ if (navigate.has(node.expression.text) || methods.has(node.expression.text)) {
1763
+ const target = extractNavigationTarget(node.arguments[0]);
1764
+ if (target) {
1765
+ addSignal(componentNavigation, currentComponentId, [target]);
1766
+ }
1767
+ }
1768
+ }
1769
+ }
1770
+ if (ts.isJsxElement(node)) {
1771
+ const tagInfo = jsxTagName(node.openingElement.tagName);
1772
+ if (tagInfo) {
1773
+ const symbol = checker.getSymbolAtLocation(node.openingElement.tagName);
1774
+ const resolved = resolveComponentFromSymbol(symbol, checker, root, componentRoots);
1775
+ if (resolved && !isUtilityComponent(resolved.name, resolved.file)) {
1776
+ registerComponent(resolved.id, resolved.name, resolved.file, "component");
1777
+ componentEdges.add(`${currentComponentId}|${resolved.id}`);
1778
+ }
1779
+ }
1780
+ if (isLinkTag(node.openingElement.tagName)) {
1781
+ const target = getJsxAttributeText(node.openingElement.attributes, "href") ??
1782
+ getJsxAttributeText(node.openingElement.attributes, "to");
1783
+ if (target) {
1784
+ addSignal(componentNavigation, currentComponentId, [target]);
1785
+ }
1786
+ }
1787
+ }
1788
+ else if (ts.isJsxSelfClosingElement(node)) {
1789
+ const tagInfo = jsxTagName(node.tagName);
1790
+ if (tagInfo) {
1791
+ const symbol = checker.getSymbolAtLocation(node.tagName);
1792
+ const resolved = resolveComponentFromSymbol(symbol, checker, root, componentRoots);
1793
+ if (resolved && !isUtilityComponent(resolved.name, resolved.file)) {
1794
+ registerComponent(resolved.id, resolved.name, resolved.file, "component");
1795
+ componentEdges.add(`${currentComponentId}|${resolved.id}`);
1796
+ }
1797
+ }
1798
+ if (isLinkTag(node.tagName)) {
1799
+ const target = getJsxAttributeText(node.attributes, "href") ??
1800
+ getJsxAttributeText(node.attributes, "to");
1801
+ if (target) {
1802
+ addSignal(componentNavigation, currentComponentId, [target]);
1803
+ }
1804
+ }
1805
+ }
1806
+ }
1807
+ ts.forEachChild(node, visit);
1808
+ };
1809
+ visit(sourceFile);
1810
+ if (route) {
1811
+ const componentName = defaultExportName ?? componentFromRoute(route);
1812
+ const id = componentId(relative, componentName);
1813
+ registerComponent(id, componentName, relative, "page");
1814
+ pageComponentIds.set(route, id);
1815
+ }
1816
+ }
1817
+ for (const [route, file] of routePageFile) {
1818
+ if (!pageComponentIds.has(route)) {
1819
+ const componentName = componentFromRoute(route);
1820
+ const id = componentId(file, componentName);
1821
+ registerComponent(id, componentName, file, "page");
1822
+ pageComponentIds.set(route, id);
1823
+ }
1824
+ }
1825
+ return {
1826
+ componentNodes,
1827
+ componentEdges,
1828
+ componentApiCalls,
1829
+ componentStateVariables,
1830
+ componentNavigation,
1831
+ pageComponentIds
1832
+ };
1833
+ }
1834
+ export async function analyzeFrontend(frontendRoot, config) {
1835
+ const root = path.resolve(frontendRoot);
1836
+ const baseRoot = path.dirname(root);
1837
+ const ignore = createIgnoreMatcher(config, baseRoot);
1838
+ const aliases = await loadAliasMappings(root, config);
1839
+ const appDir = await findAppDir(frontendRoot);
1840
+ const componentRoots = await resolveComponentRoots(root);
1841
+ const componentRootsToUse = componentRoots.length > 0 ? componentRoots : [root];
1842
+ const pageMap = new Map();
1843
+ const routeFiles = new Set();
1844
+ const routeRoots = new Map();
1845
+ const pageFileToRoute = new Map();
1846
+ const routePageFile = new Map();
1847
+ const addRouteRoot = (route, file) => {
1848
+ const current = routeRoots.get(route) ?? new Set();
1849
+ current.add(file);
1850
+ routeRoots.set(route, current);
1851
+ };
1852
+ const fileRouter = await detectFileRouterFramework(frontendRoot);
1853
+ if (appDir) {
1854
+ if (fileRouter === "expo-router") {
1855
+ // Expo Router: every .tsx in app/ is a page (except _layout, _error, etc.)
1856
+ const allAppFiles = (await listFiles(appDir, ignore, baseRoot)).filter((f) => isExpoRouterPage(f, appDir));
1857
+ for (const filePath of allAppFiles) {
1858
+ const route = expoRouteFromFile(appDir, filePath);
1859
+ const relative = toPosix(path.relative(root, filePath));
1860
+ routeFiles.add(relative);
1861
+ addRouteRoot(route, relative);
1862
+ pageFileToRoute.set(relative, route);
1863
+ if (!routePageFile.has(route)) {
1864
+ routePageFile.set(route, relative);
1865
+ }
1866
+ if (!pageMap.has(route)) {
1867
+ pageMap.set(route, {
1868
+ path: route,
1869
+ component: componentFromRoute(route)
1870
+ });
1871
+ }
1872
+ }
1873
+ }
1874
+ else {
1875
+ // Next.js / default: only page.tsx files are pages
1876
+ const routeFilesInApp = (await listFiles(appDir, ignore, baseRoot)).filter(isRouteFile);
1877
+ for (const filePath of routeFilesInApp) {
1878
+ const route = routeFromFile(appDir, filePath);
1879
+ const relative = toPosix(path.relative(root, filePath));
1880
+ routeFiles.add(relative);
1881
+ addRouteRoot(route, relative);
1882
+ if (isPageFile(filePath)) {
1883
+ pageFileToRoute.set(relative, route);
1884
+ if (!routePageFile.has(route)) {
1885
+ routePageFile.set(route, relative);
1886
+ }
1887
+ if (!pageMap.has(route)) {
1888
+ pageMap.set(route, {
1889
+ path: route,
1890
+ component: componentFromRoute(route)
1891
+ });
1892
+ }
1893
+ }
1894
+ }
1895
+ }
1896
+ }
1897
+ const apiCalls = [];
1898
+ const apiCallMap = new Map();
1899
+ const files = (await listFiles(root, ignore, baseRoot)).filter(isCodeFile);
1900
+ const routeDirs = await resolveRouteDirs(root, config.frontend?.routeDirs ?? []);
1901
+ const routeScanSet = new Set();
1902
+ if (routeDirs.length === 0) {
1903
+ for (const file of files) {
1904
+ routeScanSet.add(file);
1905
+ }
1906
+ }
1907
+ else {
1908
+ for (const file of files) {
1909
+ if (routeDirs.some((dir) => isSubPath(dir, file))) {
1910
+ routeScanSet.add(file);
1911
+ }
1912
+ }
1913
+ }
1914
+ const relativeFiles = files.map((filePath) => toPosix(path.relative(root, filePath))).sort();
1915
+ const knownFiles = new Set(relativeFiles);
1916
+ const fileGraph = new Map();
1917
+ for (const file of relativeFiles) {
1918
+ ensureNode(fileGraph, file);
1919
+ }
1920
+ const fileGraphEdges = [];
1921
+ const fileInbound = new Map();
1922
+ const fileExports = new Map();
1923
+ const fileExportDetails = new Map();
1924
+ const defaultExportNames = new Map();
1925
+ const fileUsedSymbols = new Map();
1926
+ const fileWildcardUse = new Set();
1927
+ const recordUsage = (targetFile, symbols, wildcard) => {
1928
+ if (wildcard) {
1929
+ fileWildcardUse.add(targetFile);
1930
+ return;
1931
+ }
1932
+ if (symbols.length === 0) {
1933
+ return;
1934
+ }
1935
+ const current = fileUsedSymbols.get(targetFile) ?? new Set();
1936
+ for (const symbol of symbols) {
1937
+ current.add(symbol);
1938
+ }
1939
+ fileUsedSymbols.set(targetFile, current);
1940
+ };
1941
+ for (const filePath of files) {
1942
+ let content = "";
1943
+ try {
1944
+ content = await fs.readFile(filePath, "utf8");
1945
+ }
1946
+ catch {
1947
+ continue;
1948
+ }
1949
+ const source = toPosix(path.relative(root, filePath));
1950
+ const parsed = parseJsFile(content, filePath);
1951
+ if (parsed.exports.length > 0) {
1952
+ fileExports.set(source, parsed.exports);
1953
+ }
1954
+ if (parsed.exportDetails.length > 0) {
1955
+ fileExportDetails.set(source, parsed.exportDetails);
1956
+ }
1957
+ defaultExportNames.set(source, parsed.defaultExportName);
1958
+ for (const call of parsed.apiCalls) {
1959
+ const key = `${call.method}|${call.url}|${source}`;
1960
+ const existing = apiCallMap.get(key);
1961
+ if (!existing) {
1962
+ apiCallMap.set(key, {
1963
+ method: call.method,
1964
+ path: call.url,
1965
+ source,
1966
+ request_fields: call.requestFields
1967
+ });
1968
+ }
1969
+ else {
1970
+ existing.request_fields = Array.from(new Set([...(existing.request_fields ?? []), ...call.requestFields])).sort((a, b) => a.localeCompare(b));
1971
+ }
1972
+ }
1973
+ if (routeScanSet.has(filePath)) {
1974
+ for (const route of parsed.routes) {
1975
+ const normalizedRoute = normalizeRoutePath(route);
1976
+ if (!pageMap.has(normalizedRoute)) {
1977
+ pageMap.set(normalizedRoute, {
1978
+ path: normalizedRoute,
1979
+ component: componentFromRoute(normalizedRoute)
1980
+ });
1981
+ }
1982
+ addRouteRoot(normalizedRoute, source);
1983
+ }
1984
+ if (parsed.routes.length > 0) {
1985
+ routeFiles.add(source);
1986
+ }
1987
+ }
1988
+ for (const usage of parsed.usages) {
1989
+ const resolved = await resolveJsImport(filePath, usage.specifier, aliases);
1990
+ if (!resolved) {
1991
+ continue;
1992
+ }
1993
+ const resolvedRel = toPosix(path.relative(root, resolved));
1994
+ if (!knownFiles.has(resolvedRel)) {
1995
+ continue;
1996
+ }
1997
+ addEdge(fileGraph, source, resolvedRel);
1998
+ recordUsage(resolvedRel, usage.symbols, usage.wildcard);
1999
+ }
2000
+ }
2001
+ for (const [from, targets] of fileGraph) {
2002
+ for (const to of targets) {
2003
+ fileGraphEdges.push({ from, to });
2004
+ }
2005
+ }
2006
+ fileGraphEdges.sort((a, b) => {
2007
+ const from = a.from.localeCompare(b.from);
2008
+ if (from !== 0)
2009
+ return from;
2010
+ return a.to.localeCompare(b.to);
2011
+ });
2012
+ const inbound = inboundCounts(fileGraph, relativeFiles);
2013
+ for (const [file, count] of inbound) {
2014
+ fileInbound.set(file, count);
2015
+ }
2016
+ const frontendEntrypoints = new Set([
2017
+ "src/main.tsx",
2018
+ "src/main.jsx",
2019
+ "src/main.ts",
2020
+ "src/main.js",
2021
+ "src/index.tsx",
2022
+ "src/index.jsx",
2023
+ "src/index.ts",
2024
+ "src/index.js",
2025
+ "main.tsx",
2026
+ "main.jsx",
2027
+ "main.ts",
2028
+ "main.js",
2029
+ "index.tsx",
2030
+ "index.jsx",
2031
+ "index.ts",
2032
+ "index.js"
2033
+ ].filter((entry) => relativeFiles.includes(entry)));
2034
+ const orphanFiles = relativeFiles
2035
+ .filter((file) => (fileInbound.get(file) ?? 0) === 0)
2036
+ .filter((file) => !routeFiles.has(file))
2037
+ .filter((file) => !frontendEntrypoints.has(file))
2038
+ .sort((a, b) => a.localeCompare(b));
2039
+ const unusedExports = [];
2040
+ for (const [file, symbols] of fileExports) {
2041
+ if (fileWildcardUse.has(file)) {
2042
+ continue;
2043
+ }
2044
+ const used = fileUsedSymbols.get(file) ?? new Set();
2045
+ for (const symbol of symbols) {
2046
+ if (!used.has(symbol)) {
2047
+ unusedExports.push({ file, symbol });
2048
+ }
2049
+ }
2050
+ }
2051
+ unusedExports.sort((a, b) => {
2052
+ const fileCmp = a.file.localeCompare(b.file);
2053
+ if (fileCmp !== 0)
2054
+ return fileCmp;
2055
+ return a.symbol.localeCompare(b.symbol);
2056
+ });
2057
+ const componentFiles = files.filter(isTsProgramFile);
2058
+ const componentAnalysis = await analyzeComponentsAst({
2059
+ root,
2060
+ files: componentFiles,
2061
+ pageFileToRoute,
2062
+ routePageFile,
2063
+ pageMap,
2064
+ componentRoots: componentRootsToUse,
2065
+ fileExportDetails,
2066
+ defaultExportNames,
2067
+ config
2068
+ });
2069
+ const { componentNodes, componentEdges, componentApiCalls, componentStateVariables, componentNavigation, pageComponentIds } = componentAnalysis;
2070
+ apiCalls.push(...apiCallMap.values());
2071
+ apiCalls.sort((a, b) => {
2072
+ const method = a.method.localeCompare(b.method);
2073
+ if (method !== 0)
2074
+ return method;
2075
+ const pathCmp = a.path.localeCompare(b.path);
2076
+ if (pathCmp !== 0)
2077
+ return pathCmp;
2078
+ return a.source.localeCompare(b.source);
2079
+ });
2080
+ const pages = Array.from(pageMap.values()).sort((a, b) => a.path.localeCompare(b.path));
2081
+ const componentAdjacency = new Map();
2082
+ for (const node of componentNodes.values()) {
2083
+ ensureNode(componentAdjacency, node.id);
2084
+ }
2085
+ const componentEdgeList = [];
2086
+ for (const edgeKey of componentEdges) {
2087
+ const [from, to] = edgeKey.split("|");
2088
+ if (!from || !to) {
2089
+ continue;
2090
+ }
2091
+ addEdge(componentAdjacency, from, to);
2092
+ componentEdgeList.push({ from, to });
2093
+ }
2094
+ componentEdgeList.sort((a, b) => {
2095
+ const from = a.from.localeCompare(b.from);
2096
+ if (from !== 0)
2097
+ return from;
2098
+ return a.to.localeCompare(b.to);
2099
+ });
2100
+ const componentNameById = new Map();
2101
+ for (const node of componentNodes.values()) {
2102
+ componentNameById.set(node.id, node.name);
2103
+ }
2104
+ const collectReachable = (start) => {
2105
+ const visited = new Set();
2106
+ const stack = [start];
2107
+ while (stack.length > 0) {
2108
+ const current = stack.pop();
2109
+ if (!current || visited.has(current)) {
2110
+ continue;
2111
+ }
2112
+ visited.add(current);
2113
+ const neighbors = componentAdjacency.get(current);
2114
+ if (!neighbors) {
2115
+ continue;
2116
+ }
2117
+ for (const neighbor of neighbors) {
2118
+ if (!visited.has(neighbor)) {
2119
+ stack.push(neighbor);
2120
+ }
2121
+ }
2122
+ }
2123
+ return Array.from(visited);
2124
+ };
2125
+ const gatherComponentValues = (ids, map) => {
2126
+ const result = new Set();
2127
+ for (const id of ids) {
2128
+ const values = map.get(id);
2129
+ if (!values) {
2130
+ continue;
2131
+ }
2132
+ for (const value of values) {
2133
+ result.add(value);
2134
+ }
2135
+ }
2136
+ return Array.from(result).sort((a, b) => a.localeCompare(b));
2137
+ };
2138
+ const uxPages = pages.map((page) => {
2139
+ const pageComponentId = pageComponentIds.get(page.path) ?? componentId(`route:${page.path}`, page.component);
2140
+ const directIds = Array.from(componentAdjacency.get(pageComponentId) ?? []);
2141
+ directIds.sort((a, b) => a.localeCompare(b));
2142
+ const reachable = collectReachable(pageComponentId);
2143
+ const reachableSet = new Set(reachable);
2144
+ reachableSet.delete(pageComponentId);
2145
+ for (const id of directIds) {
2146
+ reachableSet.delete(id);
2147
+ }
2148
+ const descendantIds = Array.from(reachableSet).sort((a, b) => a.localeCompare(b));
2149
+ const pageComponentIdSet = new Set([pageComponentId, ...directIds, ...descendantIds]);
2150
+ const componentsDirect = directIds.map((id) => componentNameById.get(id) ?? id);
2151
+ const componentsDesc = descendantIds.map((id) => componentNameById.get(id) ?? id);
2152
+ const componentsAll = [...new Set([...componentsDirect, ...componentsDesc])].sort((a, b) => a.localeCompare(b));
2153
+ const componentApiCallsList = Array.from(pageComponentIdSet)
2154
+ .sort((a, b) => a.localeCompare(b))
2155
+ .map((id) => ({
2156
+ component: componentNameById.get(id) ?? id,
2157
+ component_id: id,
2158
+ api_calls: Array.from(componentApiCalls.get(id) ?? []).sort((a, b) => a.localeCompare(b))
2159
+ }))
2160
+ .filter((entry) => entry.api_calls.length > 0);
2161
+ const componentStateList = Array.from(pageComponentIdSet)
2162
+ .sort((a, b) => a.localeCompare(b))
2163
+ .map((id) => ({
2164
+ component: componentNameById.get(id) ?? id,
2165
+ component_id: id,
2166
+ local_state_variables: Array.from(componentStateVariables.get(id) ?? []).sort((a, b) => a.localeCompare(b))
2167
+ }))
2168
+ .filter((entry) => entry.local_state_variables.length > 0);
2169
+ return {
2170
+ path: page.path,
2171
+ component: page.component,
2172
+ component_id: pageComponentId,
2173
+ components: componentsAll,
2174
+ components_direct: componentsDirect,
2175
+ components_descendants: componentsDesc,
2176
+ components_direct_ids: directIds,
2177
+ components_descendants_ids: descendantIds,
2178
+ local_state_variables: gatherComponentValues(pageComponentIdSet, componentStateVariables),
2179
+ api_calls: gatherComponentValues(pageComponentIdSet, componentApiCalls),
2180
+ component_api_calls: componentApiCallsList,
2181
+ component_state_variables: componentStateList,
2182
+ possible_navigation: gatherComponentValues(pageComponentIdSet, componentNavigation)
2183
+ };
2184
+ });
2185
+ const componentList = Array.from(componentNodes.values()).sort((a, b) => {
2186
+ const nameCmp = a.name.localeCompare(b.name);
2187
+ if (nameCmp !== 0)
2188
+ return nameCmp;
2189
+ return a.file.localeCompare(b.file);
2190
+ });
2191
+ // --- 6. Extract Tests Natively using Universal Adapters ---
2192
+ const tests = [];
2193
+ for (const relativeFile of relativeFiles) {
2194
+ if (!relativeFile.includes("test") && !relativeFile.includes("spec") && !relativeFile.includes("Test"))
2195
+ continue;
2196
+ const absoluteFile = path.join(baseRoot, relativeFile);
2197
+ try {
2198
+ const content = await fs.readFile(absoluteFile, "utf8");
2199
+ const adapter = getAdapterForFile(relativeFile);
2200
+ if (adapter && adapter.queries.tests) {
2201
+ const result = runAdapter(adapter, relativeFile, content);
2202
+ tests.push(...result.tests);
2203
+ }
2204
+ }
2205
+ catch {
2206
+ // gracefully ignore unparseable or inaccessible test files
2207
+ }
2208
+ }
2209
+ return {
2210
+ files: relativeFiles,
2211
+ pages,
2212
+ apiCalls,
2213
+ uxPages,
2214
+ components: componentList,
2215
+ componentGraph: componentEdgeList,
2216
+ fileGraph: fileGraphEdges,
2217
+ orphanFiles,
2218
+ unusedExports,
2219
+ tests
2220
+ };
2221
+ }