diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 96909de7c4..d06c54c315 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -13,7 +13,6 @@ import { Text, } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Sentry from '@sentry/react-native'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; @@ -29,6 +28,7 @@ import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { uploadImage } from 'expo-app/features/packs/utils/uploadImage'; import { ProfileAuthWall } from 'expo-app/features/profile/components'; import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { cn } from 'expo-app/lib/cn'; import { hasUnsyncedChanges } from 'expo-app/lib/hasUnsyncedChanges'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 86b3c654c7..4affe59a8e 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -1,5 +1,4 @@ import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; @@ -18,6 +17,7 @@ import { import { DeleteAccountButton } from 'expo-app/features/auth/components/DeleteAccountButton'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index dafa9dca02..a2e8a4554b 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -1,6 +1,5 @@ import type { AlertMethods } from '@packrat/ui/nativewindui'; import { ActivityIndicator, AlertAnchor, Button, Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { featureFlags } from 'expo-app/config'; import { isLoadingAtom, @@ -8,6 +7,7 @@ import { redirectToAtom, } from 'expo-app/features/auth/atoms/authAtoms'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; import { Link, router, useLocalSearchParams } from 'expo-router'; diff --git a/apps/expo/atoms/atomWithAsyncStorage.ts b/apps/expo/atoms/atomWithAsyncStorage.ts index 49060418e7..9e033b4f3b 100644 --- a/apps/expo/atoms/atomWithAsyncStorage.ts +++ b/apps/expo/atoms/atomWithAsyncStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { atom } from 'jotai'; export const atomWithAsyncStorage = ({ diff --git a/apps/expo/atoms/atomWithKvStorage.ts b/apps/expo/atoms/atomWithKvStorage.ts index 886827e7d7..8a265c85cf 100644 --- a/apps/expo/atoms/atomWithKvStorage.ts +++ b/apps/expo/atoms/atomWithKvStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; import { atom } from 'jotai'; export const atomWithKvStorage = ({ key, initialValue }: { key: string; initialValue: T }) => { diff --git a/apps/expo/features/ai/atoms/chatStorageAtoms.ts b/apps/expo/features/ai/atoms/chatStorageAtoms.ts index 0ef7a482b9..76e363fbe2 100644 --- a/apps/expo/features/ai/atoms/chatStorageAtoms.ts +++ b/apps/expo/features/ai/atoms/chatStorageAtoms.ts @@ -1,7 +1,7 @@ import type { UIMessage } from '@ai-sdk/react'; import { isObject, isString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Sentry from '@sentry/react-native'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; export type ChatContext = { itemId?: string; diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index bfbb9975c9..2c4312765d 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,22 +1,18 @@ import { asBoolean, asString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - GoogleSignin, - isErrorWithCode, - statusCodes, -} from '@react-native-google-signin/google-signin'; import * as Sentry from '@sentry/react-native'; import { AuthClientError, toAuthError } from 'expo-app/features/auth/lib/authErrors'; import { userStore } from 'expo-app/features/auth/store'; import type { User } from 'expo-app/features/profile/types'; import * as AppleAuthentication from 'expo-app/lib/appleAuthentication'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { authClient } from 'expo-app/lib/auth-client'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; +import { GoogleSignin, isErrorWithCode, statusCodes } from 'expo-app/lib/googleSignin'; import { t } from 'expo-app/lib/i18n'; import * as Updates from 'expo-app/lib/updates'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import { queryClient } from 'expo-app/providers/TanstackProvider'; import { type Href, router } from 'expo-router'; -import Storage from 'expo-sqlite/kv-store'; import { useAtomValue, useSetAtom } from 'jotai'; import { isLoadingAtom, diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 6a19507a42..8bff2b0efb 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -2,13 +2,13 @@ import { when } from '@legendapp/state'; import { WEIGHT_UNITS } from '@packrat/constants'; import { clientEnvs } from '@packrat/env/expo-client'; import { asBoolean, asString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { GoogleSignin } from '@react-native-google-signin/google-signin'; import * as Sentry from '@sentry/react-native'; import { userStore, userSyncState } from 'expo-app/features/auth/store'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { authClient } from 'expo-app/lib/auth-client'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; +import { GoogleSignin } from 'expo-app/lib/googleSignin'; import { router } from 'expo-router'; -import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; diff --git a/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts b/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts index 61ccdaa76f..a7812e248d 100644 --- a/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts +++ b/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts @@ -1,9 +1,9 @@ import { useSelector } from '@legendapp/state/react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Sentry from '@sentry/react-native'; import { useQuery } from '@tanstack/react-query'; import { userStore } from 'expo-app/features/auth/store/user'; import { apiClient } from 'expo-app/lib/api/packrat'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; import { useEffect, useRef, useState } from 'react'; import { trailConditionReportsStore } from '../store/trailConditionReports'; diff --git a/apps/expo/features/weather/screens/LocationSearchScreen.tsx b/apps/expo/features/weather/screens/LocationSearchScreen.tsx index c7bbad5872..889578c751 100644 --- a/apps/expo/features/weather/screens/LocationSearchScreen.tsx +++ b/apps/expo/features/weather/screens/LocationSearchScreen.tsx @@ -1,8 +1,8 @@ import { Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; import { SearchInput } from 'expo-app/components/SearchInput'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts b/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts index 7c53db4725..0e88a14228 100644 --- a/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts +++ b/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts @@ -1,4 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { atomWithStorage, createJSONStorage, loadable } from 'jotai/utils'; import type { WildlifeIdentification } from '../types'; diff --git a/apps/expo/lib/asyncStorage.ts b/apps/expo/lib/asyncStorage.ts new file mode 100644 index 0000000000..e01d3622e7 --- /dev/null +++ b/apps/expo/lib/asyncStorage.ts @@ -0,0 +1,5 @@ +// @react-native-async-storage/async-storage ships a localStorage-backed web +// implementation out of the box, so a single re-export works on every +// platform. This wrapper exists so call sites never import the native module +// path directly (per the lib/ wrapper convention). +export { default } from '@react-native-async-storage/async-storage'; diff --git a/apps/expo/lib/expoSqliteKvStore.ts b/apps/expo/lib/expoSqliteKvStore.ts new file mode 100644 index 0000000000..01fd07038c --- /dev/null +++ b/apps/expo/lib/expoSqliteKvStore.ts @@ -0,0 +1,5 @@ +// Default export is the `Storage` singleton from expo-sqlite/kv-store, an +// AsyncStorage-compatible store with extra synchronous methods (getItemSync, +// setItemSync). The native module has no working web build, so .web.ts backs +// the same surface with localStorage. +export { default } from 'expo-sqlite/kv-store'; diff --git a/apps/expo/lib/expoSqliteKvStore.web.ts b/apps/expo/lib/expoSqliteKvStore.web.ts new file mode 100644 index 0000000000..648dac54ed --- /dev/null +++ b/apps/expo/lib/expoSqliteKvStore.web.ts @@ -0,0 +1,61 @@ +// expo-sqlite/kv-store relies on the native SQLite module, which has no usable +// web build. Back the same API (async aliases + sync variants + getAllKeys) +// with localStorage so callers need no Platform branches on web. + +const getItemSync = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +const setItemSync = (key: string, value: string): void => { + localStorage.setItem(key, value); +}; + +const removeItemSync = (key: string): void => { + localStorage.removeItem(key); +}; + +const getAllKeysSync = (): string[] => { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) keys.push(key); + } + return keys; +}; + +const Storage = { + getItem: (key: string): Promise => Promise.resolve(getItemSync(key)), + setItem: (key: string, value: string): Promise => { + setItemSync(key, value); + return Promise.resolve(); + }, + removeItem: (key: string): Promise => { + removeItemSync(key); + return Promise.resolve(); + }, + getAllKeys: (): Promise => Promise.resolve(getAllKeysSync()), + clear: (): Promise => { + localStorage.clear(); + return Promise.resolve(); + }, + getItemAsync: (key: string): Promise => Promise.resolve(getItemSync(key)), + setItemAsync: (key: string, value: string): Promise => { + setItemSync(key, value); + return Promise.resolve(); + }, + removeItemAsync: (key: string): Promise => { + removeItemSync(key); + return Promise.resolve(true); + }, + getAllKeysAsync: (): Promise => Promise.resolve(getAllKeysSync()), + getItemSync, + setItemSync, + removeItemSync, + getAllKeysSync, +}; + +export default Storage; diff --git a/apps/expo/lib/googleSignin.ts b/apps/expo/lib/googleSignin.ts new file mode 100644 index 0000000000..f43454403c --- /dev/null +++ b/apps/expo/lib/googleSignin.ts @@ -0,0 +1,9 @@ +export { + GoogleSignin, + GoogleSigninButton, + isCancelledResponse, + isErrorWithCode, + isNoSavedCredentialFoundResponse, + isSuccessResponse, + statusCodes, +} from '@react-native-google-signin/google-signin'; diff --git a/apps/expo/lib/googleSignin.web.ts b/apps/expo/lib/googleSignin.web.ts new file mode 100644 index 0000000000..f397c32eb4 --- /dev/null +++ b/apps/expo/lib/googleSignin.web.ts @@ -0,0 +1,34 @@ +// @react-native-google-signin/google-signin is a native module with no usable +// web implementation. Native Google Sign-In is not wired up for web (web auth +// uses a different OAuth flow), so this stub keeps the bundle building and +// fails loudly only if a web caller actually tries to trigger native sign-in. + +const notAvailable = (): never => { + throw new Error('Native Google Sign-In is not available on web.'); +}; + +export const GoogleSignin = { + configure: () => {}, + hasPlayServices: () => Promise.resolve(false), + signIn: notAvailable, + signInSilently: notAvailable, + getTokens: notAvailable, + hasPreviousSignIn: () => false, + signOut: () => Promise.resolve(null), + revokeAccess: () => Promise.resolve(null), + getCurrentUser: () => null, +}; + +export const statusCodes = { + SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', + IN_PROGRESS: 'IN_PROGRESS', + PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', + SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED', +} as const; + +export const isErrorWithCode = (_error: unknown): boolean => false; +export const isSuccessResponse = (_response: unknown): boolean => false; +export const isCancelledResponse = (_response: unknown): boolean => false; +export const isNoSavedCredentialFoundResponse = (_response: unknown): boolean => false; + +export const GoogleSigninButton = (): null => null; diff --git a/apps/expo/lib/kvStorage.ts b/apps/expo/lib/kvStorage.ts index d21ac4e6e4..fb63ba1f1b 100644 --- a/apps/expo/lib/kvStorage.ts +++ b/apps/expo/lib/kvStorage.ts @@ -1,4 +1,4 @@ -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; export default { getItem: (key: string): string | null => Storage.getItemSync(key), diff --git a/apps/expo/lib/kvStorage.web.ts b/apps/expo/lib/kvStorage.web.ts index c9c9b9db28..611299e3cf 100644 --- a/apps/expo/lib/kvStorage.web.ts +++ b/apps/expo/lib/kvStorage.web.ts @@ -1,4 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; export default { getItem: (key: string) => AsyncStorage.getItem(key), diff --git a/apps/expo/lib/persist-plugin.ts b/apps/expo/lib/persist-plugin.ts index 322561d52f..91fd460cb9 100644 --- a/apps/expo/lib/persist-plugin.ts +++ b/apps/expo/lib/persist-plugin.ts @@ -1,4 +1,4 @@ import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; export const persistPlugin = observablePersistSqlite(Storage); diff --git a/apps/expo/lib/persist-plugin.web.ts b/apps/expo/lib/persist-plugin.web.ts index a92c6d1914..aa77b260cf 100644 --- a/apps/expo/lib/persist-plugin.web.ts +++ b/apps/expo/lib/persist-plugin.web.ts @@ -1,5 +1,5 @@ import { observablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; // On web, the expo-sqlite persist plugin requires SharedArrayBuffer (COEP/COOP // headers). Use the AsyncStorage plugin instead, which falls through to our diff --git a/apps/expo/utils/storage.ts b/apps/expo/utils/storage.ts index abbab9a7da..f11446aeeb 100644 --- a/apps/expo/utils/storage.ts +++ b/apps/expo/utils/storage.ts @@ -1,5 +1,5 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import type { WeatherLocation } from 'expo-app/features/weather/types'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { createJSONStorage } from 'jotai/utils'; // Create a storage adapter for Jotai that uses AsyncStorage diff --git a/scripts/lint/no-direct-wrapped-imports.ts b/scripts/lint/no-direct-wrapped-imports.ts index 6998292aab..f45b43bd09 100644 --- a/scripts/lint/no-direct-wrapped-imports.ts +++ b/scripts/lint/no-direct-wrapped-imports.ts @@ -1,52 +1,101 @@ #!/usr/bin/env bun // -// no-direct-wrapped-imports.ts — enforces the lib/ wrapper convention. +// no-direct-wrapped-imports.ts — enforces the apps/expo/lib/ wrapper convention. // -// Some external modules are wrapped in `apps/expo/lib/` so callers get a single -// import that works across platforms (the `.web` variant handles browser -// behaviour). Once a module is wrapped, it must be imported from the wrapper -// everywhere else — importing the raw dependency directly bypasses the web -// shim and reintroduces platform branches. +// Platform-sensitive external dependencies (native modules, expo-* packages +// that are stubbed or throw on web, etc.) must be imported through a wrapper +// module in apps/expo/lib/ rather than directly. The wrapper's `.web.ts` +// variant handles browser behavior so callers need no `Platform.OS` branches +// and the web bundle (`expo export -p web`) keeps working. // -// To wrap a new module, add it to WRAPPED below and create lib/.ts -// (+ lib/.web.ts as needed). +// Each entry in WRAPPED maps a module specifier to the wrapper that should be +// imported instead. A direct import of a wrapped module anywhere in apps/expo +// (outside the wrapper itself and test/spec files) is a violation. // // Exit code: // 0 — no violations // 1 — violations found (details printed to stdout) +// +// Wired into the `lint:custom` script in root package.json. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; const ROOT = join(import.meta.dir, '..', '..'); -const ROOTS = ['apps', 'packages']; -const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.wrangler', '.expo']); +const EXPO_ROOT = join(ROOT, 'apps', 'expo'); -// Raw module specifier → the wrapper module that owns it. Only the wrapper file -// (and its platform variants) may import the raw module. -const WRAPPED: Record = { - 'expo-secure-store': 'apps/expo/lib/secureStore', - 'expo-apple-authentication': 'apps/expo/lib/appleAuthentication', - 'expo-updates': 'apps/expo/lib/updates', -}; +// module specifier (or specifier prefix) → wrapper import path the call site +// should use instead. The `wrapperFile` is the wrapper's source path relative +// to apps/expo, used to exempt the wrapper itself from the rule. +interface WrappedEntry { + /** Import specifier that is forbidden outside the wrapper. */ + module: string; + /** Wrapper path callers should import from instead. */ + wrapper: string; + /** Wrapper source files (relative to apps/expo) exempt from the rule. */ + wrapperFiles: string[]; +} -const VARIANT = /\.(web|native|ios|android)$/; +const WRAPPED: WrappedEntry[] = [ + { + module: 'expo-secure-store', + wrapper: 'expo-app/lib/secureStore', + wrapperFiles: ['lib/secureStore.ts', 'lib/secureStore.web.ts'], + }, + { + module: 'expo-apple-authentication', + wrapper: 'expo-app/lib/appleAuthentication', + wrapperFiles: ['lib/appleAuthentication.ts', 'lib/appleAuthentication.web.ts'], + }, + { + module: 'expo-updates', + wrapper: 'expo-app/lib/updates', + wrapperFiles: ['lib/updates.ts', 'lib/updates.web.ts'], + }, + { + module: '@react-native-async-storage/async-storage', + wrapper: 'expo-app/lib/asyncStorage', + wrapperFiles: ['lib/asyncStorage.ts'], + }, + { + module: '@react-native-google-signin/google-signin', + wrapper: 'expo-app/lib/googleSignin', + wrapperFiles: ['lib/googleSignin.ts', 'lib/googleSignin.web.ts'], + }, + { + module: 'expo-sqlite/kv-store', + wrapper: 'expo-app/lib/expoSqliteKvStore', + wrapperFiles: ['lib/expoSqliteKvStore.ts', 'lib/expoSqliteKvStore.web.ts'], + }, +]; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.expo', '.wrangler']); + +// app.config.ts / app.json reference plugin names as build-time strings, not +// runtime imports — they are not call sites and are exempt. +const EXEMPT_FILES = new Set(['app.config.ts', 'app.config.js', 'metro.config.js']); -function ownsModule(relPathNoExt: string, wrapper: string): boolean { - return relPathNoExt === wrapper || relPathNoExt.replace(VARIANT, '') === wrapper; +function isTargetFile(name: string): boolean { + return /\.(ts|tsx|cts|mts)$/.test(name) && !/\.(test|spec)\.(ts|tsx|cts|mts)$/.test(name); } -function isTargetFile(name: string): boolean { - return /\.(ts|tsx|cts|mts)$/.test(name); +function buildImportPattern(module: string): RegExp { + // Matches `from 'module'` and `from 'module/subpath'` in both import and + // export-from statements, plus bare `import 'module'` side-effect imports. + const escaped = module.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:from|import)\\s+['"]${escaped}(?:/[^'"]*)?['"]`); } interface Violation { file: string; line: number; - module: string; + content: string; wrapper: string; } +const wrapperFileSet = new Set(WRAPPED.flatMap((e) => e.wrapperFiles)); +const patterns = WRAPPED.map((e) => ({ ...e, pattern: buildImportPattern(e.module) })); + function walkDir(dir: string, relPath: string, violations: Violation[]): void { let entries: string[]; try { @@ -57,8 +106,9 @@ function walkDir(dir: string, relPath: string, violations: Violation[]): void { for (const entry of entries) { if (EXCLUDED_DIRS.has(entry)) continue; + const entryFull = join(dir, entry); - const entryRel = `${relPath}/${entry}`; + const entryRel = relPath ? `${relPath}/${entry}` : entry; let isDir = false; try { @@ -71,9 +121,11 @@ function walkDir(dir: string, relPath: string, violations: Violation[]): void { walkDir(entryFull, entryRel, violations); continue; } + if (!isTargetFile(entry)) continue; + if (wrapperFileSet.has(entryRel)) continue; + if (EXEMPT_FILES.has(entry)) continue; - const relNoExt = entryRel.replace(/\.(ts|tsx|cts|mts)$/, ''); let content: string; try { content = readFileSync(entryFull, 'utf-8'); @@ -84,11 +136,9 @@ function walkDir(dir: string, relPath: string, violations: Violation[]): void { const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; - for (const [mod, wrapper] of Object.entries(WRAPPED)) { - // import ... from 'mod' / require('mod') / import('mod') - const re = new RegExp(`(from|require\\(|import\\()\\s*['"]${mod}['"]`); - if (re.test(line) && !ownsModule(relNoExt, wrapper)) { - violations.push({ file: entryRel, line: i + 1, module: mod, wrapper }); + for (const { pattern, wrapper } of patterns) { + if (pattern.test(line)) { + violations.push({ file: entryRel, line: i + 1, content: line.trim(), wrapper }); } } } @@ -96,18 +146,17 @@ function walkDir(dir: string, relPath: string, violations: Violation[]): void { } const violations: Violation[] = []; -for (const root of ROOTS) { - walkDir(join(ROOT, root), root, violations); -} +walkDir(EXPO_ROOT, '', violations); if (violations.length > 0) { console.log( `Direct imports of wrapped modules found (${violations.length}) — import from the lib/ wrapper instead:\n`, ); - for (const { file, line, module, wrapper } of violations) { - console.log(`${file}:${line}: import '${module}' → use '${wrapper}'`); + for (const { file, line, content, wrapper } of violations) { + console.log(`apps/expo/${file}:${line}: ${content}`); + console.log(` → import from '${wrapper}'\n`); } process.exit(1); } -console.log('No direct imports of wrapped modules.'); +console.log('No direct imports of wrapped modules in apps/expo.'); diff --git a/scripts/lint/no-owned-max-params.ts b/scripts/lint/no-owned-max-params.ts index 0e352be846..b982dbe930 100644 --- a/scripts/lint/no-owned-max-params.ts +++ b/scripts/lint/no-owned-max-params.ts @@ -53,6 +53,8 @@ const EXCLUDED_FILES = new Set([ 'apps/trails/scripts/generate-og-images.ts', // Web shim that must mirror expo-secure-store's positional (key, value) API. 'apps/expo/lib/secureStore.web.ts', + // Web shim that must mirror expo-sqlite/kv-store's positional (key, value) API. + 'apps/expo/lib/expoSqliteKvStore.web.ts', ]); const FRAMEWORK_METHOD_NAMES = new Set(['fetch', 'queue', 'resolveRequest', 'scheduled']); const EXTERNAL_CALLBACK_NAMES = new Set([