From 359714341226cd2c6782bca437dfd5cc05b72dce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:12:55 -0700 Subject: [PATCH 1/3] refactor(mobile): break module cycles with focused extractions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile audit found real circular imports: composer attachment preview retention lived in `state/use-composer-drafts` while `lib/attachmentUpload` and `lib/localAttachmentPreview` called it and composer-draft cleanup reached back into `lib/attachmentUpload`, closing a cycle through the attachment/session state cluster; and four component pairs shared a prop/request type in one direction while importing the component in the other (`FilePreview` ↔ `FilePreviewModal` ↔ `MediaImagePreview`, `ConfirmDialogHost` ↔ `MaterialConfirmDialog`, `SegmentedControl` ↔ `MaterialSegmentedControl`, `reviewWordDiffs` ↔ `shikiReviewHighlighter`). Each cycle gets the smallest honest seam: - `lib/composerAttachmentPreviewRetention.ts` now owns the preview-retention lease; composer draft state registers the owner-side cleanup hook at load, so lib no longer imports `state/use-composer-drafts` and the cycle is gone in both directions. Behavior (retain during preview/upload, retry the unused-file sweep on last release) is unchanged. - Shared component shapes move to `.types.ts` modules (`ConfirmDialog.types.ts`, `SegmentedControl.types.ts`, `FilePreviewModal.types.ts`, `reviewHighlightedToken.types.ts`); the old homes re-export them so external call sites are untouched. - `legacy-plan-mode.ts` was pure contract-shaped model logic imported by state from `features/threads`; it moves to `state/`. - `src/dependency-graph.test.ts` is the regression check: no circular static imports (dynamic `import()` stays the documented async escape hatch), plus shrink-only ceilings on the remaining upward edges (state→features 6, lib→features 7, components→features 33, native→features 8, lib→state 11). `lib/runtime.ts` edges count toward the ceiling since it is the composition root. No HomeScreen, thread-list, or package.json changes. Done by callstack/Apex in pi. --- .../src/components/ConfirmDialog.types.ts | 18 ++ .../src/components/ConfirmDialogHost.tsx | 20 +- .../mobile/src/components/FilePreview.ios.tsx | 2 +- apps/mobile/src/components/FilePreview.tsx | 2 +- .../src/components/FilePreviewModal.tsx | 21 +- .../src/components/FilePreviewModal.types.ts | 21 ++ .../src/components/MaterialConfirmDialog.tsx | 2 +- .../MaterialSegmentedButtons.android.tsx | 2 +- .../MaterialSegmentedControl.android.tsx | 2 +- .../components/MaterialSegmentedControl.tsx | 2 +- .../src/components/MediaImagePreview.tsx | 2 +- .../src/components/SegmentedControl.tsx | 16 +- .../src/components/SegmentedControl.types.ts | 14 + apps/mobile/src/dependency-graph.test.ts | 245 ++++++++++++++++++ .../features/review/reviewDiffRendering.tsx | 2 +- .../review/reviewHighlightedToken.types.ts | 6 + .../src/features/review/reviewWordDiffs.ts | 2 +- .../features/review/shikiReviewHighlighter.ts | 9 +- .../threads/new-task-flow-provider.tsx | 2 +- .../threads/use-legacy-plan-mode-enabled.ts | 2 +- apps/mobile/src/lib/attachmentUpload.test.ts | 2 +- apps/mobile/src/lib/attachmentUpload.ts | 2 +- .../lib/composerAttachmentPreviewRetention.ts | 28 ++ .../src/lib/localAttachmentPreview.test.ts | 2 +- apps/mobile/src/lib/localAttachmentPreview.ts | 2 +- .../src/state/composer-attachment-uploads.ts | 2 +- .../legacy-plan-mode.test.ts | 0 .../threads => state}/legacy-plan-mode.ts | 0 apps/mobile/src/state/thread-outbox-model.ts | 2 +- .../src/state/use-composer-drafts.test.ts | 2 +- apps/mobile/src/state/use-composer-drafts.ts | 21 +- .../src/state/use-thread-composer-state.ts | 2 +- 32 files changed, 372 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/src/components/ConfirmDialog.types.ts create mode 100644 apps/mobile/src/components/FilePreviewModal.types.ts create mode 100644 apps/mobile/src/components/SegmentedControl.types.ts create mode 100644 apps/mobile/src/dependency-graph.test.ts create mode 100644 apps/mobile/src/features/review/reviewHighlightedToken.types.ts create mode 100644 apps/mobile/src/lib/composerAttachmentPreviewRetention.ts rename apps/mobile/src/{features/threads => state}/legacy-plan-mode.test.ts (100%) rename apps/mobile/src/{features/threads => state}/legacy-plan-mode.ts (100%) diff --git a/apps/mobile/src/components/ConfirmDialog.types.ts b/apps/mobile/src/components/ConfirmDialog.types.ts new file mode 100644 index 000000000000..7a32787cc32d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialog.types.ts @@ -0,0 +1,18 @@ +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index e1e61cda26df..8e4d6c3130bc 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; -export interface SegmentedControlProps { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..6e9193f3bdf1 --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,245 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports. Cycles were the source of module-init hazards and + * made the state/lib/features boundaries unmaintainable; shared shapes now + * live in `.types.ts` modules (e.g. `ConfirmDialog.types.ts`) and the + * composer preview-retention lease lives in `lib/`. + * Dynamic `import("...")` calls are excluded on purpose: they are the + * deliberate async escape hatch (e.g. composer-draft cleanup reaching + * `lib/attachmentUpload`), and Metro resolves them after both modules have + * initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). The ceiling may only shrink: when you remove one of + * these imports, lower the constant in the same PR. + */ + +const SOURCE_ROOT = __dirname; + +/** Metro-style resolution order for relative specifiers. */ +const RESOLVE_CANDIDATES = [ + ".ts", + ".tsx", + ".ios.ts", + ".ios.tsx", + ".android.ts", + ".android.tsx", +] as const; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative(fromFile: string, specifier: string): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const candidate of [ + ...RESOLVE_CANDIDATES.map((ext) => base + ext), + ...RESOLVE_CANDIDATES.map((ext) => NodePath.join(base, `index${ext}`)), + ]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface Graph { + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Every cross-layer edge, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlyArray; +} + +function buildGraph(): Graph { + const files = collectSourceFiles(SOURCE_ROOT).sort(); + const staticEdges = new Map(); + const crossLayerEdges: string[] = []; + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier); + if (resolved === null || resolved === file) { + continue; + } + crossLayerEdges.push( + `${NodePath.relative(SOURCE_ROOT, file)} -> ${NodePath.relative(SOURCE_ROOT, resolved)}`, + ); + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: Graph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array = [[root, 0]]; + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + enter(root); + while (work.length > 0) { + const [node, edge] = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(node) ?? []; + let advanced = false; + for (let i = edge; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [node, i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(node, Math.min(low.get(node)!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(node)!)); + } + if (low.get(node) === index.get(node)) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== node); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graph = buildGraph(); +const edgesBetween = (from: Layer, to: Layer): string[] => + graph.crossLayerEdges + .filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }) + .sort(); + +describe("mobile dependency graph", () => { + it("has no circular imports among source modules", () => { + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + // The graph must see real files; a resolution regression here would make + // every ceiling vacuously pass. + expect(graph.files.length).toBeGreaterThan(400); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const edges = edgesBetween(from, to); + expect( + edges.length, + `${message}. ${edges.length} edges remain:\n${edges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d00cf2be4daf..818f5ab8af47 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; export const REVIEW_MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", diff --git a/apps/mobile/src/features/review/reviewHighlightedToken.types.ts b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts new file mode 100644 index 000000000000..58418a32cc7c --- /dev/null +++ b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts @@ -0,0 +1,6 @@ +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts index 34ac9bc7a746..8dd258a296cc 100644 --- a/apps/mobile/src/features/review/reviewWordDiffs.ts +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -1,6 +1,6 @@ import { diffWordsWithSpace } from "diff"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; interface ReviewDiffOperation { readonly value: string; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 3050f1f67ee8..9b69c8d552ea 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr } } -export interface ReviewHighlightedToken { - content: string; - readonly color: string | null; - readonly fontStyle: number | null; - readonly diffHighlight?: boolean; -} +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; + +export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..4d069bca9d05 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9f5dad4d5600..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment, From 8e33b05ef2832986c2626504f28b1f77f969db9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:22:31 -0700 Subject: [PATCH 2/3] fix(mobile): catch platform-hidden type cycles in dependency guard Review of #13151 found the dependency-graph guard resolved `./X` to the generic `.tsx` before the `.android.tsx` variant, hiding a real Android cycle: `ThemedSwitch` imports `MaterialSwitch` (which resolves to the `.android.tsx` variant on Android) while that variant type-imports `ThemedSwitchProps` back from `ThemedSwitch`. - Extract `ThemedSwitchProps` to `components/MaterialSwitch.types.ts`; `ThemedSwitch` re-exports it, the Android variant imports from the types module. Acyclic under any resolution order. - The guard now models Metro's per-platform resolution (platform ext before `.native` before generic, and each platform's graph excludes the other platform's variant files) and asserts zero circular static imports under both iOS and Android resolution. Verified against the seeded Android-only cycle the review described. - Ceiling counts now dedupe unique `from -> to` module pairs across both platforms (same numbers as before: 6/7/33/8/11). Done by callstack/Apex in pi. --- .../src/components/MaterialSwitch.android.tsx | 2 +- .../src/components/MaterialSwitch.types.ts | 12 ++ apps/mobile/src/components/ThemedSwitch.tsx | 14 +- apps/mobile/src/dependency-graph.test.ts | 186 +++++++++++------- 4 files changed, 129 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/src/components/MaterialSwitch.types.ts diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts index 6e9193f3bdf1..46dc10038491 100644 --- a/apps/mobile/src/dependency-graph.test.ts +++ b/apps/mobile/src/dependency-graph.test.ts @@ -5,33 +5,35 @@ import { describe, expect, it } from "vite-plus/test"; /** * Dependency-graph guards for the mobile source tree (audit #13). * - * 1. No circular imports. Cycles were the source of module-init hazards and - * made the state/lib/features boundaries unmaintainable; shared shapes now - * live in `.types.ts` modules (e.g. `ConfirmDialog.types.ts`) and the - * composer preview-retention lease lives in `lib/`. - * Dynamic `import("...")` calls are excluded on purpose: they are the - * deliberate async escape hatch (e.g. composer-draft cleanup reaching - * `lib/attachmentUpload`), and Metro resolves them after both modules have - * initialized, so they cannot create an initialization cycle. + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. * * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, * `native`, and `components` still reach upward into `features` at known * sites (the app composition root `lib/runtime.ts` legitimately wires - * feature layers). The ceiling may only shrink: when you remove one of - * these imports, lower the constant in the same PR. + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. */ const SOURCE_ROOT = __dirname; -/** Metro-style resolution order for relative specifiers. */ -const RESOLVE_CANDIDATES = [ - ".ts", - ".tsx", - ".ios.ts", - ".ios.tsx", - ".android.ts", - ".android.tsx", -] as const; +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; const isGraphFile = (filePath: string): boolean => /\.tsx?$/.test(filePath) && @@ -39,6 +41,15 @@ const isGraphFile = (filePath: string): boolean => !filePath.includes("test-support") && !filePath.endsWith(".d.ts"); +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + function collectSourceFiles(dir: string, out: string[] = []): string[] { for (const entry of NodeFS.readdirSync(dir)) { const filePath = NodePath.join(dir, entry); @@ -51,22 +62,25 @@ function collectSourceFiles(dir: string, out: string[] = []): string[] { return out; } -function resolveRelative(fromFile: string, specifier: string): string | null { +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { if (!specifier.startsWith(".")) { return null; } const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); - for (const candidate of [ - ...RESOLVE_CANDIDATES.map((ext) => base + ext), - ...RESOLVE_CANDIDATES.map((ext) => NodePath.join(base, `index${ext}`)), - ]) { - try { - if (NodeFS.statSync(candidate).isFile()) { - const resolved = NodePath.resolve(candidate); - return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. } - } catch { - // Candidate does not exist; try the next one. } } return null; @@ -104,39 +118,53 @@ function layerOf(relativePath: string): Layer { : "other"; } -interface Graph { +interface PlatformGraph { + readonly platform: Platform; readonly files: ReadonlyArray; /** Static (type or value) import edges keyed by source file. */ readonly staticEdges: ReadonlyMap>; - /** Every cross-layer edge, static and dynamic, as "fromRel -> toRel". */ - readonly crossLayerEdges: ReadonlyArray; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; } -function buildGraph(): Graph { - const files = collectSourceFiles(SOURCE_ROOT).sort(); +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); const staticEdges = new Map(); - const crossLayerEdges: string[] = []; + const crossLayerEdges = new Set(); for (const file of files) { const targets = new Set(); for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { - const resolved = resolveRelative(file, specifier); + const resolved = resolveRelative(file, specifier, extensionOrder); if (resolved === null || resolved === file) { continue; } - crossLayerEdges.push( - `${NodePath.relative(SOURCE_ROOT, file)} -> ${NodePath.relative(SOURCE_ROOT, resolved)}`, - ); + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } if (!isDynamic) { targets.add(resolved); } } staticEdges.set(file, [...targets]); } - return { files, staticEdges, crossLayerEdges }; + return { platform, files, staticEdges, crossLayerEdges }; } /** Tarjan strongly-connected components, iterative to bound stack depth. */ -function findCycles(graph: Graph): ReadonlyArray> { +function findCycles(graph: PlatformGraph): ReadonlyArray> { const index = new Map(); const low = new Map(); const onStack = new Set(); @@ -144,48 +172,49 @@ function findCycles(graph: Graph): ReadonlyArray> { const cycles: string[][] = []; let nextIndex = 0; + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + for (const root of graph.files) { if (index.has(root)) continue; - const work: Array = [[root, 0]]; - const enter = (node: string): void => { - index.set(node, nextIndex); - low.set(node, nextIndex); - nextIndex += 1; - stack.push(node); - onStack.add(node); - }; + const work: Array<[string, number]> = [[root, 0]]; enter(root); while (work.length > 0) { - const [node, edge] = work[work.length - 1]!; - const neighbors = graph.staticEdges.get(node) ?? []; + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; let advanced = false; - for (let i = edge; i < neighbors.length; i += 1) { + for (let i = frame[1]; i < neighbors.length; i += 1) { const child = neighbors[i]!; if (!graph.staticEdges.has(child)) continue; if (!index.has(child)) { - work[work.length - 1] = [node, i + 1]; + work[work.length - 1] = [frame[0], i + 1]; work.push([child, 0]); enter(child); advanced = true; break; } else if (onStack.has(child)) { - low.set(node, Math.min(low.get(node)!, index.get(child)!)); + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); } } if (advanced) continue; work.pop(); const parent = work[work.length - 1]; if (parent) { - low.set(parent[0], Math.min(low.get(parent[0])!, low.get(node)!)); + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); } - if (low.get(node) === index.get(node)) { + if (low.get(frame[0]) === index.get(frame[0])) { const component: string[] = []; let member: string; do { member = stack.pop()!; onStack.delete(member); component.push(member); - } while (member !== node); + } while (member !== frame[0]); if (component.length > 1) { cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); } @@ -195,24 +224,35 @@ function findCycles(graph: Graph): ReadonlyArray> { return cycles; } -const graph = buildGraph(); -const edgesBetween = (from: Layer, to: Layer): string[] => - graph.crossLayerEdges - .filter((edge) => { - const [source, target] = edge.split(" -> "); - return layerOf(source!) === from && layerOf(target!) === to; - }) - .sort(); +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} describe("mobile dependency graph", () => { - it("has no circular imports among source modules", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); expect(findCycles(graph)).toEqual([]); }); it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { - // The graph must see real files; a resolution regression here would make - // every ceiling vacuously pass. - expect(graph.files.length).toBeGreaterThan(400); + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); const ceilings: ReadonlyArray = [ // state -> features: thread ordering reaching the thread-list model, @@ -235,10 +275,10 @@ describe("mobile dependency graph", () => { ]; for (const [from, to, ceiling, message] of ceilings) { - const edges = edgesBetween(from, to); + const layerEdges = edgesFor(from, to); expect( - edges.length, - `${message}. ${edges.length} edges remain:\n${edges.join("\n")}`, + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, ).toBeLessThanOrEqual(ceiling); } }); From 5ebd0c9739febef11b6224485b6d48b20b98705a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:30:08 -0700 Subject: [PATCH 3/3] docs(mobile): note that the dependency-graph guard runs in CI The Test job's package-wide `vp run test` already covers `@t3tools/mobile`; record it so nobody adds a redundant workflow step. Done by callstack/Apex in pi. --- apps/mobile/src/dependency-graph.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts index 46dc10038491..f4c953925bcc 100644 --- a/apps/mobile/src/dependency-graph.test.ts +++ b/apps/mobile/src/dependency-graph.test.ts @@ -21,6 +21,9 @@ import { describe, expect, it } from "vite-plus/test"; * feature layers). Ceilings count unique `from -> to` module pairs across * all platforms, including dynamic imports, and may only shrink: when you * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). */ const SOURCE_ROOT = __dirname;