// rnx compat — scan dependencies with the shared compatibility engine. import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { COMPAT_RESULT_CAVEAT, formatDetailedScanLines, formatScanCounts, scanDeps, type ScanResult, } from '@sootsim/compat/web' import { findProjectRoot, scanWorkspace } from './detect' interface PackageManifest { name?: string dependencies?: Record devDependencies?: Record } export async function runCompat(args: string[]): Promise { if (args.includes('--help') || args.includes('-h')) { console.log(` rnx compat — check package compatibility usage: rnx compat [package-name] rnx compat --json rnx compat --brief flags: --json machine-readable output --brief one-line summary --app target directory (default: cwd) `) return } const appIndex = args.indexOf('--app') const appDir = appIndex >= 0 && args[appIndex + 1] ? resolve(args[appIndex + 1]) : process.cwd() const packageName = args.find( (arg, index) => !arg.startsWith('-') && (appIndex < 0 || index !== appIndex + 1), ) const pkg = readPackageJson(appDir) if (!pkg) { console.error(` no package.json found in ${appDir}`) process.exitCode = 1 return } if (packageName) { report(args, scanOnePackage(appDir, pkg, packageName)) return } const here = scanDeps(installedManifest(appDir, pkg)) if (here.packages.length > 0 || appIndex >= 0) { report(args, here) return } // a monorepo root manifest is not the app. it carries react-native and expo // so the workspace resolves and little else, so scanning it reports a // project with nothing to check while the real app sits in a subdirectory. const workspaceRoot = findProjectRoot(appDir) const elsewhere = scanWorkspace(workspaceRoot).apps.flatMap((app) => { if (resolve(app.dir) === appDir) return [] const manifest = readPackageJson(app.dir) if (!manifest) return [] const result = scanDeps(installedManifest(app.dir, manifest)) return result.packages.length > 0 ? [{ dir: app.dir, result }] : [] }) if (elsewhere.length === 1) { const only = elsewhere[0] console.log(`\n scanning ${relative(appDir, only.dir) || '.'}`) report(args, only.result) return } if (elsewhere.length > 1) { console.log(`\n this workspace has more than one app. scan one with --app:`) for (const entry of elsewhere) { console.log(` rnx compat --app ${relative(appDir, entry.dir)}`) } console.log() return } report(args, here) } function report(args: string[], result: ScanResult): void { if (args.includes('--json')) { console.log(JSON.stringify(result, null, 2)) return } if (args.includes('--brief')) { console.log(` ${formatScanCounts(result)}`) console.log(` ${COMPAT_RESULT_CAVEAT}`) return } console.log(`\n rnx compatibility report`) console.log(` ${formatScanCounts(result)}\n`) for (const line of formatDetailedScanLines(result)) console.log(` ${line}`) const supported = result.packages.filter((item) => item.status === 'full').length if (supported > 0) console.log(` supported: ${supported} packages`) const notRelevant = result.packages.filter( (item) => item.status === 'not-relevant', ).length if (notRelevant > 0) console.log(` not relevant at runtime: ${notRelevant} packages`) if (result.packages.length === 0) { console.log(` no React Native ecosystem packages detected`) } console.log(`\n ${COMPAT_RESULT_CAVEAT}\n`) } // the declared range is often not a version: pnpm and bun catalogs write // `catalog:`, a workspace dependency writes `workspace:*`, and a pinned tarball // writes a URL. none of those match a verified version range, so a whole app // reports as unverified. the installed tree holds the version that will // actually run, so ask it, and keep the declared range for anything the project // has not installed. function installedManifest(appDir: string, pkg: PackageManifest): PackageManifest { const resolveVersion = (name: string, declared: string): string => { let dir = appDir while (true) { const manifest = join(dir, 'node_modules', name, 'package.json') if (existsSync(manifest)) { try { const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8')) if (parsed && typeof parsed === 'object' && 'version' in parsed) { const version = parsed.version if (typeof version === 'string') return version } } catch {} return declared } const parent = dirname(dir) if (parent === dir) return declared dir = parent } } const mapped = (deps: Record | undefined) => deps && Object.fromEntries( Object.entries(deps).map(([name, declared]) => [ name, resolveVersion(name, declared), ]), ) return { name: pkg.name, dependencies: mapped(pkg.dependencies), devDependencies: mapped(pkg.devDependencies), } } function readPackageJson(appDir: string): PackageManifest | null { const packagePath = resolve(appDir, 'package.json') if (!existsSync(packagePath)) return null const parsed: unknown = JSON.parse(readFileSync(packagePath, 'utf8')) return parsed && typeof parsed === 'object' ? parsed : null } function scanOnePackage( appDir: string, pkg: PackageManifest, packageName: string, ): ScanResult { const installed = installedManifest(appDir, pkg) const version = installed.dependencies?.[packageName] ?? installed.devDependencies?.[packageName] ?? '*' return scanDeps({ dependencies: { [packageName]: version } }) }