| 1 | import * as NodeFS from "node:fs"; |
| 2 | import * as NodePath from "node:path"; |
| 3 | import { 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 | |
| 29 | const SOURCE_ROOT = __dirname; |
| 30 | |
| 31 | /** Metro candidate order per platform (platform, then native, then generic). */ |
| 32 | const 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 | |
| 37 | type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; |
| 38 | |
| 39 | const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray<Platform>; |
| 40 | |
| 41 | const 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. */ |
| 48 | function 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 | |
| 56 | function 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 | |
| 68 | function 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 | |
| 92 | interface ParsedImport { |
| 93 | readonly specifier: string; |
| 94 | readonly isDynamic: boolean; |
| 95 | } |
| 96 | |
| 97 | function 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 | |
| 114 | type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; |
| 115 | |
| 116 | function 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 | |
| 124 | interface 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 | |
| 133 | function 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. */ |
| 170 | function 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 | |
| 230 | const graphs = PLATFORMS.map(buildPlatformGraph); |
| 231 | |
| 232 | /** Unique upward edge pairs across every platform, sorted for stable diffs. */ |
| 233 | function 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 | |
| 243 | describe("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 | }); |