1import * as NodeFS from "node:fs";
2import * as NodePath from "node:path";
3import { describe, expect, it } from "vite-plus/test";
4
5/** Dynamic imports cannot create Metro initialization cycles: they resolve after both modules initialize. */
6
7const SOURCE_ROOT = __dirname;
8
9const PLATFORM_EXTENSION_ORDER = {
10 android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"],
11 ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"],
12} as const;
13
14type Platform = keyof typeof PLATFORM_EXTENSION_ORDER;
15
16const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray<Platform>;
17
18const isGraphFile = (filePath: string): boolean =>
19 /\.tsx?$/.test(filePath) &&
20 !filePath.includes(".test.") &&
21 !filePath.includes("test-support") &&
22 !filePath.endsWith(".d.ts");
23
24function isRelevantForPlatform(filePath: string, platform: Platform): boolean {
25 const name = NodePath.basename(filePath);
26 if (platform === "android") {
27 return !name.includes(".ios.");
28 }
29 return !name.includes(".android.");
30}
31
32function collectSourceFiles(dir: string, out: string[] = []): string[] {
33 for (const entry of NodeFS.readdirSync(dir)) {
34 const filePath = NodePath.join(dir, entry);
35 if (NodeFS.statSync(filePath).isDirectory()) {
36 collectSourceFiles(filePath, out);
37 } else if (isGraphFile(filePath)) {
38 out.push(filePath);
39 }
40 }
41 return out;
42}
43
44function resolveRelative(
45 fromFile: string,
46 specifier: string,
47 extensionOrder: ReadonlyArray<string>,
48): string | null {
49 if (!specifier.startsWith(".")) {
50 return null;
51 }
52 const base = NodePath.resolve(NodePath.dirname(fromFile), specifier);
53 for (const ext of extensionOrder) {
54 for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) {
55 try {
56 if (NodeFS.statSync(candidate).isFile()) {
57 const resolved = NodePath.resolve(candidate);
58 return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null;
59 }
60 } catch {}
61 }
62 }
63 return null;
64}
65
66interface ParsedImport {
67 readonly specifier: string;
68 readonly isDynamic: boolean;
69}
70
71function parseImports(source: string): ParsedImport[] {
72 const parsed: ParsedImport[] = [];
73 const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g;
74 const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g;
75 const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
76 for (const match of source.matchAll(staticRe)) {
77 parsed.push({ specifier: match[1]!, isDynamic: false });
78 }
79 for (const match of source.matchAll(bareRe)) {
80 parsed.push({ specifier: match[1]!, isDynamic: false });
81 }
82 for (const match of source.matchAll(dynamicRe)) {
83 parsed.push({ specifier: match[1]!, isDynamic: true });
84 }
85 return parsed;
86}
87
88type Layer = "state" | "lib" | "components" | "native" | "features" | "other";
89
90function layerOf(relativePath: string): Layer {
91 const top = relativePath.split(NodePath.sep)[0]!;
92 if (top === "features") return "features";
93 return top === "state" || top === "lib" || top === "components" || top === "native"
94 ? (top as Layer)
95 : "other";
96}
97
98interface PlatformGraph {
99 readonly platform: Platform;
100 readonly files: ReadonlyArray<string>;
101 readonly staticEdges: ReadonlyMap<string, ReadonlyArray<string>>;
102 readonly crossLayerEdges: ReadonlySet<string>;
103}
104
105function buildPlatformGraph(platform: Platform): PlatformGraph {
106 const extensionOrder = PLATFORM_EXTENSION_ORDER[platform];
107 const files = collectSourceFiles(SOURCE_ROOT)
108 .filter((file) => isRelevantForPlatform(file, platform))
109 .sort();
110 const staticEdges = new Map<string, string[]>();
111 const crossLayerEdges = new Set<string>();
112 for (const file of files) {
113 const targets = new Set<string>();
114 for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) {
115 const resolved = resolveRelative(file, specifier, extensionOrder);
116 if (resolved === null || resolved === file) {
117 continue;
118 }
119 const from = NodePath.relative(SOURCE_ROOT, file);
120 const to = NodePath.relative(SOURCE_ROOT, resolved);
121 const fromLayer = layerOf(from);
122 const toLayer = layerOf(to);
123 const upward =
124 (fromLayer === "state" ||
125 fromLayer === "lib" ||
126 fromLayer === "components" ||
127 fromLayer === "native") &&
128 (toLayer === "features" || (fromLayer === "lib" && toLayer === "state"));
129 if (upward) {
130 crossLayerEdges.add(`${from} -> ${to}`);
131 }
132 if (!isDynamic) {
133 targets.add(resolved);
134 }
135 }
136 staticEdges.set(file, [...targets]);
137 }
138 return { platform, files, staticEdges, crossLayerEdges };
139}
140
141function findCycles(graph: PlatformGraph): ReadonlyArray<ReadonlyArray<string>> {
142 const index = new Map<string, number>();
143 const low = new Map<string, number>();
144 const onStack = new Set<string>();
145 const stack: string[] = [];
146 const cycles: string[][] = [];
147 let nextIndex = 0;
148
149 const enter = (node: string): void => {
150 index.set(node, nextIndex);
151 low.set(node, nextIndex);
152 nextIndex += 1;
153 stack.push(node);
154 onStack.add(node);
155 };
156
157 for (const root of graph.files) {
158 if (index.has(root)) continue;
159 const work: Array<[string, number]> = [[root, 0]];
160 enter(root);
161 while (work.length > 0) {
162 const frame = work[work.length - 1]!;
163 const neighbors = graph.staticEdges.get(frame[0]) ?? [];
164 let advanced = false;
165 for (let i = frame[1]; i < neighbors.length; i += 1) {
166 const child = neighbors[i]!;
167 if (!graph.staticEdges.has(child)) continue;
168 if (!index.has(child)) {
169 work[work.length - 1] = [frame[0], i + 1];
170 work.push([child, 0]);
171 enter(child);
172 advanced = true;
173 break;
174 } else if (onStack.has(child)) {
175 low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!));
176 }
177 }
178 if (advanced) continue;
179 work.pop();
180 const parent = work[work.length - 1];
181 if (parent) {
182 low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!));
183 }
184 if (low.get(frame[0]) === index.get(frame[0])) {
185 const component: string[] = [];
186 let member: string;
187 do {
188 member = stack.pop()!;
189 onStack.delete(member);
190 component.push(member);
191 } while (member !== frame[0]);
192 if (component.length > 1) {
193 cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file)));
194 }
195 }
196 }
197 }
198 return cycles;
199}
200
201const graphs = PLATFORMS.map(buildPlatformGraph);
202
203describe("mobile dependency graph", () => {
204 it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => {
205 const graph = graphs.find((candidate) => candidate.platform === platform)!;
206 expect(graph.files.length).toBeGreaterThan(500);
207 expect(findCycles(graph)).toEqual([]);
208 });
209});