1import * as NodeFS from "node:fs";
2import * as NodePath from "node:path";
3import { describe, expect, it } from "vite-plus/test";
4
5/**
6 * Dependency-graph guards for the mobile source tree (audit #13).
7 *
8 * 1. No circular imports, checked once per platform the way Metro resolves
9 * modules (`<name>.<platform>.*` before `<name>.native.*` before the
10 * generic file). A cycle can hide behind platform resolution: a `.tsx`
11 * base importing a component whose `.android.tsx` variant type-imports
12 * back into the base is acyclic on iOS but circular on Android.
13 * Dynamic `import("...")` calls are excluded from this rule on purpose:
14 * they are the deliberate async escape hatch (e.g. composer-draft cleanup
15 * reaching `lib/attachmentUpload`), and Metro resolves them after both
16 * modules have initialized, so they cannot create an initialization cycle.
17 *
18 * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`,
19 * `native`, and `components` still reach upward into `features` at known
20 * sites (the app composition root `lib/runtime.ts` legitimately wires
21 * feature layers). Ceilings count unique `from -> to` module pairs across
22 * all platforms, including dynamic imports, and may only shrink: when you
23 * remove one of these imports, lower the constant in the same PR.
24 *
25 * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up
26 * the `@t3tools/mobile` package test task).
27 */
28
29const SOURCE_ROOT = __dirname;
30
31/** Metro candidate order per platform (platform, then native, then generic). */
32const PLATFORM_EXTENSION_ORDER = {
33 android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"],
34 ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"],
35} as const;
36
37type Platform = keyof typeof PLATFORM_EXTENSION_ORDER;
38
39const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray<Platform>;
40
41const isGraphFile = (filePath: string): boolean =>
42 /\.tsx?$/.test(filePath) &&
43 !filePath.includes(".test.") &&
44 !filePath.includes("test-support") &&
45 !filePath.endsWith(".d.ts");
46
47/** Files Metro would not even bundle for the other platform. */
48function isRelevantForPlatform(filePath: string, platform: Platform): boolean {
49 const name = NodePath.basename(filePath);
50 if (platform === "android") {
51 return !name.includes(".ios.");
52 }
53 return !name.includes(".android.");
54}
55
56function collectSourceFiles(dir: string, out: string[] = []): string[] {
57 for (const entry of NodeFS.readdirSync(dir)) {
58 const filePath = NodePath.join(dir, entry);
59 if (NodeFS.statSync(filePath).isDirectory()) {
60 collectSourceFiles(filePath, out);
61 } else if (isGraphFile(filePath)) {
62 out.push(filePath);
63 }
64 }
65 return out;
66}
67
68function resolveRelative(
69 fromFile: string,
70 specifier: string,
71 extensionOrder: ReadonlyArray<string>,
72): string | null {
73 if (!specifier.startsWith(".")) {
74 return null;
75 }
76 const base = NodePath.resolve(NodePath.dirname(fromFile), specifier);
77 for (const ext of extensionOrder) {
78 for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) {
79 try {
80 if (NodeFS.statSync(candidate).isFile()) {
81 const resolved = NodePath.resolve(candidate);
82 return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null;
83 }
84 } catch {
85 // Candidate does not exist; try the next one.
86 }
87 }
88 }
89 return null;
90}
91
92interface ParsedImport {
93 readonly specifier: string;
94 readonly isDynamic: boolean;
95}
96
97function parseImports(source: string): ParsedImport[] {
98 const parsed: ParsedImport[] = [];
99 const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g;
100 const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g;
101 const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
102 for (const match of source.matchAll(staticRe)) {
103 parsed.push({ specifier: match[1]!, isDynamic: false });
104 }
105 for (const match of source.matchAll(bareRe)) {
106 parsed.push({ specifier: match[1]!, isDynamic: false });
107 }
108 for (const match of source.matchAll(dynamicRe)) {
109 parsed.push({ specifier: match[1]!, isDynamic: true });
110 }
111 return parsed;
112}
113
114type Layer = "state" | "lib" | "components" | "native" | "features" | "other";
115
116function layerOf(relativePath: string): Layer {
117 const top = relativePath.split(NodePath.sep)[0]!;
118 if (top === "features") return "features";
119 return top === "state" || top === "lib" || top === "components" || top === "native"
120 ? (top as Layer)
121 : "other";
122}
123
124interface PlatformGraph {
125 readonly platform: Platform;
126 readonly files: ReadonlyArray<string>;
127 /** Static (type or value) import edges keyed by source file. */
128 readonly staticEdges: ReadonlyMap<string, ReadonlyArray<string>>;
129 /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */
130 readonly crossLayerEdges: ReadonlySet<string>;
131}
132
133function buildPlatformGraph(platform: Platform): PlatformGraph {
134 const extensionOrder = PLATFORM_EXTENSION_ORDER[platform];
135 const files = collectSourceFiles(SOURCE_ROOT)
136 .filter((file) => isRelevantForPlatform(file, platform))
137 .sort();
138 const staticEdges = new Map<string, string[]>();
139 const crossLayerEdges = new Set<string>();
140 for (const file of files) {
141 const targets = new Set<string>();
142 for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) {
143 const resolved = resolveRelative(file, specifier, extensionOrder);
144 if (resolved === null || resolved === file) {
145 continue;
146 }
147 const from = NodePath.relative(SOURCE_ROOT, file);
148 const to = NodePath.relative(SOURCE_ROOT, resolved);
149 const fromLayer = layerOf(from);
150 const toLayer = layerOf(to);
151 const upward =
152 (fromLayer === "state" ||
153 fromLayer === "lib" ||
154 fromLayer === "components" ||
155 fromLayer === "native") &&
156 (toLayer === "features" || (fromLayer === "lib" && toLayer === "state"));
157 if (upward) {
158 crossLayerEdges.add(`${from} -> ${to}`);
159 }
160 if (!isDynamic) {
161 targets.add(resolved);
162 }
163 }
164 staticEdges.set(file, [...targets]);
165 }
166 return { platform, files, staticEdges, crossLayerEdges };
167}
168
169/** Tarjan strongly-connected components, iterative to bound stack depth. */
170function findCycles(graph: PlatformGraph): ReadonlyArray<ReadonlyArray<string>> {
171 const index = new Map<string, number>();
172 const low = new Map<string, number>();
173 const onStack = new Set<string>();
174 const stack: string[] = [];
175 const cycles: string[][] = [];
176 let nextIndex = 0;
177
178 const enter = (node: string): void => {
179 index.set(node, nextIndex);
180 low.set(node, nextIndex);
181 nextIndex += 1;
182 stack.push(node);
183 onStack.add(node);
184 };
185
186 for (const root of graph.files) {
187 if (index.has(root)) continue;
188 const work: Array<[string, number]> = [[root, 0]];
189 enter(root);
190 while (work.length > 0) {
191 const frame = work[work.length - 1]!;
192 const neighbors = graph.staticEdges.get(frame[0]) ?? [];
193 let advanced = false;
194 for (let i = frame[1]; i < neighbors.length; i += 1) {
195 const child = neighbors[i]!;
196 if (!graph.staticEdges.has(child)) continue;
197 if (!index.has(child)) {
198 work[work.length - 1] = [frame[0], i + 1];
199 work.push([child, 0]);
200 enter(child);
201 advanced = true;
202 break;
203 } else if (onStack.has(child)) {
204 low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!));
205 }
206 }
207 if (advanced) continue;
208 work.pop();
209 const parent = work[work.length - 1];
210 if (parent) {
211 low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!));
212 }
213 if (low.get(frame[0]) === index.get(frame[0])) {
214 const component: string[] = [];
215 let member: string;
216 do {
217 member = stack.pop()!;
218 onStack.delete(member);
219 component.push(member);
220 } while (member !== frame[0]);
221 if (component.length > 1) {
222 cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file)));
223 }
224 }
225 }
226 }
227 return cycles;
228}
229
230const graphs = PLATFORMS.map(buildPlatformGraph);
231
232/** Unique upward edge pairs across every platform, sorted for stable diffs. */
233function upwardEdges(): string[] {
234 const union = new Set<string>();
235 for (const graph of graphs) {
236 for (const edge of graph.crossLayerEdges) {
237 union.add(edge);
238 }
239 }
240 return [...union].sort();
241}
242
243describe("mobile dependency graph", () => {
244 it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => {
245 const graph = graphs.find((candidate) => candidate.platform === platform)!;
246 // The graph must see real files; a resolution regression here would make
247 // both rules vacuously pass.
248 expect(graph.files.length).toBeGreaterThan(500);
249 expect(findCycles(graph)).toEqual([]);
250 });
251
252 it("keeps upward imports from state/lib/components/native into features at the ceiling", () => {
253 const edges = upwardEdges();
254 const edgesFor = (from: Layer, to: Layer): string[] =>
255 edges.filter((edge) => {
256 const [source, target] = edge.split(" -> ");
257 return layerOf(source!) === from && layerOf(target!) === to;
258 });
259
260 const ceilings: ReadonlyArray<readonly [Layer, Layer, number, string]> = [
261 // state -> features: thread ordering reaching the thread-list model,
262 // the incoming-share store, the connection controller hook, the
263 // terminal launch context, and the pending message feed.
264 // (legacy-plan-mode was pure model logic and moved into state/.)
265 ["state", "features", 6, "state must not add imports from features"],
266 // lib -> features: lib/runtime.ts is the app composition root and
267 // legitimately wires cloud/observability features; the appearance
268 // helpers and terminal preferences still need untangling.
269 ["lib", "features", 7, "lib must not add imports from features"],
270 // components -> features: mostly the appearance preferences provider
271 // and the layout toolbar bridges.
272 ["components", "features", 33, "components must not add imports from features"],
273 // native -> features: native glue reading appearance/keyboard/review features.
274 ["native", "features", 8, "native must not add imports from features"],
275 // lib -> state: attachment/session plumbing that predates the cycle
276 // cleanup; each remaining edge needs a real owner-side seam.
277 ["lib", "state", 10, "lib must not add imports from state"],
278 ];
279
280 for (const [from, to, ceiling, message] of ceilings) {
281 const layerEdges = edgesFor(from, to);
282 expect(
283 layerEdges.length,
284 `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`,
285 ).toBeLessThanOrEqual(ceiling);
286 }
287 });
288});