|
| 1 | +import fs from "node:fs"; |
1 | 2 | import path from "node:path"; |
2 | 3 |
|
3 | 4 | import { |
@@ -152,7 +153,13 @@ export function scanReact(options: ScanOptions): LineageGraph { |
152 | 153 | const baseUrls = options.baseUrls ?? []; |
153 | 154 | const flagCallees = new Set<string>([...DEFAULT_FLAG_CALLEES, ...(options.featureFlags ?? [])]); |
154 | 155 |
|
| 156 | + // Honor the scanned app's own tsconfig (6F.3, failure mode A5/C1): its |
| 157 | + // baseUrl/paths make alias imports ("@ui") resolvable, which is the |
| 158 | + // difference between linking an instance to its definition and dropping the |
| 159 | + // usage entirely. File discovery stays ours (skipAddingFilesFromTsConfig). |
| 160 | + const tsConfigFilePath = path.join(root, "tsconfig.json"); |
155 | 161 | const project = new Project({ |
| 162 | + ...(fs.existsSync(tsConfigFilePath) ? { tsConfigFilePath } : {}), |
156 | 163 | compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ }, |
157 | 164 | skipAddingFilesFromTsConfig: true, |
158 | 165 | }); |
@@ -1435,14 +1442,59 @@ function materializeInstances( |
1435 | 1442 | return null; // imported from an unknown, unconfigured module — not ours |
1436 | 1443 | } |
1437 | 1444 |
|
1438 | | - // Same file first, then unique-name fallback across the project. |
| 1445 | + // Same file first, then a lazy-wrapper variable, then unique-name fallback. |
1439 | 1446 | const resolvedName = resolveAlias(pending.tagName); |
1440 | 1447 | const sameFile = nodeId("component", pending.file, resolvedName); |
1441 | 1448 | if (nodes.has(sameFile)) return { definitionId: sameFile, external: false }; |
| 1449 | + const lazy = lazyDefinition(sourceFile, pending.tagName); |
| 1450 | + if (lazy !== null) return lazy; |
1442 | 1451 | const global = definitionsByName.get(resolvedName); |
1443 | 1452 | return global !== undefined ? { definitionId: global, external: false } : null; |
1444 | 1453 | }; |
1445 | 1454 |
|
| 1455 | + /** |
| 1456 | + * <Users/> where Users = Loadable(lazy(() => import("./pages/UsersPage"))) |
| 1457 | + * or React.lazy(() => import(...)): unwrap the wrapper chain to the dynamic |
| 1458 | + * import and resolve the target module's default export (6F.3 — the field |
| 1459 | + * app wrapped every page this way). |
| 1460 | + */ |
| 1461 | + function lazyDefinition( |
| 1462 | + sourceFile: SourceFile, |
| 1463 | + tagName: string, |
| 1464 | + ): { definitionId: string; external: boolean } | null { |
| 1465 | + const variable = sourceFile.getVariableDeclaration(tagName); |
| 1466 | + const initializer = variable?.getInitializer(); |
| 1467 | + if (initializer === undefined || !Node.isCallExpression(initializer)) return null; |
| 1468 | + const importCall = [initializer, ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression)] |
| 1469 | + .find((call) => call.getExpression().getKind() === SyntaxKind.ImportKeyword); |
| 1470 | + const specifier = importCall?.getArguments()[0]; |
| 1471 | + if (specifier === undefined || !Node.isStringLiteral(specifier)) return null; |
| 1472 | + // The compiler binds a resolvable specifier to its module symbol; fall |
| 1473 | + // back to relative resolution against the importing file for odd setups. |
| 1474 | + const target = |
| 1475 | + specifier.getSymbol()?.getDeclarations().find(Node.isSourceFile) ?? |
| 1476 | + resolveRelativeModule(sourceFile, specifier.getLiteralText()); |
| 1477 | + if (target === undefined) return null; |
| 1478 | + for (const declaration of target.getExportedDeclarations().get("default") ?? []) { |
| 1479 | + const declName = Node.hasName(declaration) ? declaration.getName() : undefined; |
| 1480 | + if (declName === undefined) continue; |
| 1481 | + const declFile = toPosix(path.relative(root, declaration.getSourceFile().getFilePath())); |
| 1482 | + const candidate = nodeId("component", declFile, resolveAlias(declName)); |
| 1483 | + if (nodes.has(candidate)) return { definitionId: candidate, external: false }; |
| 1484 | + } |
| 1485 | + return null; |
| 1486 | + } |
| 1487 | + |
| 1488 | + function resolveRelativeModule(from: SourceFile, spec: string): SourceFile | undefined { |
| 1489 | + if (!spec.startsWith(".")) return undefined; |
| 1490 | + const base = path.join(path.dirname(from.getFilePath()), spec); |
| 1491 | + for (const suffix of [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts"]) { |
| 1492 | + const found = from.getProject().getSourceFile(`${base}${suffix}`); |
| 1493 | + if (found !== undefined) return found; |
| 1494 | + } |
| 1495 | + return undefined; |
| 1496 | + } |
| 1497 | + |
1446 | 1498 | const idByIndex: Array<string | null> = []; |
1447 | 1499 | const created: InstanceNode[] = []; |
1448 | 1500 | for (const [index, pending] of pendingInstances.entries()) { |
|
0 commit comments