import { join } from 'node:path' import { promisify } from 'node:util' import * as proc from 'node:child_process' import { readFile, writeFile } from 'fs-extra' import pMap from 'p-map' const exec = promisify(proc.exec) interface Package { name: string location: string } interface PackageJson { name?: string dependencies?: Record peerDependencies?: Record devDependencies?: Record optionalDependencies?: Record } interface MissingDepReport { packageName: string location: string missingDeps: string[] allImports: string[] } async function findAllPackages(): Promise { // console.info('[DEBUG] findAllPackages: reading root package.json...') // Read workspace patterns from package.json const rootPkgJson = JSON.parse( await readFile(join(process.cwd(), 'package.json'), { encoding: 'utf-8' }) ) // console.info('[DEBUG] findAllPackages: got root package.json') // Use shell find command instead of fast-glob (bun compatibility) // console.info('[DEBUG] findAllPackages: finding package.json files with shell...') const { stdout } = await exec( `find ./pkgs -name package.json -type f -not -path "*/node_modules/*"`, { cwd: process.cwd(), maxBuffer: 10 * 1024 * 1024 } ) const packageJsonPaths = stdout.trim().split('\n').filter(Boolean) // console.info('[DEBUG] findAllPackages: found', packageJsonPaths.length, 'paths') // Read each package.json and extract name and location const packages: Package[] = [] for (const pkgPath of packageJsonPaths) { try { const location = pkgPath.replace(/^\.\//, '').replace('/package.json', '') const pkgJson: PackageJson = JSON.parse( await readFile(join(process.cwd(), pkgPath), { encoding: 'utf-8' }) ) if (pkgJson.name) { packages.push({ name: pkgJson.name, location, }) } } catch { // Skip packages that can't be read } } // Add root package packages.unshift({ name: rootPkgJson.name || 'root', location: '.', }) return packages } async function getPackageJson(location: string): Promise { try { const jsonPath = join(process.cwd(), location, 'package.json') const fileContents = await readFile(jsonPath, { encoding: 'utf-8' }) return JSON.parse(fileContents) } catch (err) { return null } } // Cache for all imports across all packages - populated once by scanAllImports let allImportsCache: Map | null = null function extractPackageName(imp: string): string { if (imp.startsWith('@')) { // Scoped package: @scope/package-name/sub-path -> @scope/package-name const parts = imp.split('/') return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : imp } else { // Regular package: package-name/sub-path -> package-name return imp.split('/')[0] } } function parseImportsFromOutput(stdout: string): Map> { const result = new Map>() for (const line of stdout.split('\n')) { if (!line) continue // Format is: path/to/file.tsx:from 'package-name' const colonIdx = line.indexOf(':from ') if (colonIdx === -1) continue const filePath = line.substring(0, colonIdx) const importPart = line.substring(colonIdx + 1) // Extract package from "from 'package'" or 'from "package"' const quoteMatch = importPart.match(/from ['"]([^'"]+)['"]/) if (!quoteMatch) continue const importPath = quoteMatch[1] // Skip relative imports, node: prefixed, etc if ( importPath.startsWith('.') || importPath.startsWith('/') || importPath.startsWith('~') || importPath.startsWith('node:') ) { continue } const packageName = extractPackageName(importPath) // Find the package directory (everything before /src/) const srcIdx = filePath.indexOf('/src/') if (srcIdx === -1) continue const pkgDir = filePath.substring(0, srcIdx) if (!result.has(pkgDir)) { result.set(pkgDir, new Set()) } result.get(pkgDir)!.add(packageName) } return result } async function scanAllImports(): Promise> { if (allImportsCache) return allImportsCache // console.info('[DEBUG] scanAllImports: checking rg...') // Check if rg is available try { await exec('which rg') // console.info('[DEBUG] scanAllImports: rg found') } catch { console.warn( 'Warning: ripgrep (rg) is not installed. Please install it with: brew install ripgrep' ) process.exit(0) } // console.info('[DEBUG] scanAllImports: running ripgrep...') // Single ripgrep scan of entire code directory let stdout = '' try { // Use simpler grep pattern with escaped quotes const result = await exec( String.raw`rg "from ['\x22]" ./pkgs --glob "**/src/**/*.tsx" --glob "**/src/**/*.ts" --glob "!**/*.test.ts" --glob "!**/*.test.tsx" --glob "!**/node_modules/**" --with-filename`, { cwd: process.cwd(), maxBuffer: 50 * 1024 * 1024, // 50MB buffer } ) stdout = result.stdout // console.info('[DEBUG] scanAllImports: ripgrep done, got', stdout.length, 'chars') } catch (err: any) { if (err.code === 1) { stdout = '' // console.info('[DEBUG] scanAllImports: ripgrep returned empty') } else { throw err } } const importsByDir = parseImportsFromOutput(stdout) // Convert Sets to Arrays allImportsCache = new Map() for (const [dir, imports] of importsByDir) { allImportsCache.set(dir, [...imports]) } return allImportsCache } async function findImports(location: string): Promise { // Skip if no location or root location if (!location || location === '.' || location === '') { return [] } const allImports = await scanAllImports() // Try to find imports for this location // The location might be like "pkgs/core/web" and we stored "./pkgs/core/web" const normalizedLocation = location.startsWith('./') ? location : `./${location}` return allImports.get(normalizedLocation) || allImports.get(location) || [] } async function analyzePackage(pkg: Package): Promise { const packageJson = await getPackageJson(pkg.location) if (!packageJson) { return null } const allImports = await findImports(pkg.location) if (allImports.length === 0) { return null } // Get all declared dependencies const allDeps = new Set([ ...Object.keys(packageJson.dependencies || {}), ...Object.keys(packageJson.peerDependencies || {}), ...Object.keys(packageJson.devDependencies || {}), ...Object.keys(packageJson.optionalDependencies || {}), ]) // Find missing dependencies let missingDeps = allImports.filter((imp) => !allDeps.has(imp)) // Filter out vite, test, and blacklisted dependencies missingDeps = missingDeps.filter((dep) => { const isViteOrTest = dep.includes('vite') || dep.includes('test') // bun is a runtime environment like node, not a package dependency // expo-image is an optional dependency // expo-linear-gradient is handled by expo // moti is deprecated and animations-moti package intentionally doesn't list it const isBlacklisted = dep === 'expo-linear-gradient' || dep === 'bun' || dep === 'expo-image' || dep === 'moti' // Filter out self-references (package importing itself, often from JSDoc comments) const isSelfReference = dep === pkg.name return !isViteOrTest && !isBlacklisted && !isSelfReference }) if (missingDeps.length === 0) { return null } return { packageName: pkg.name, location: pkg.location, missingDeps, allImports, } } async function getReactNativeVersion(): Promise { const kitchenSinkPath = join(process.cwd(), 'apps/kitchen-sink/package.json') const kitchenSinkJson = JSON.parse( await readFile(kitchenSinkPath, { encoding: 'utf-8' }) ) return kitchenSinkJson.dependencies['react-native'] || '^0.79.2' } async function fixGuiDependencies(pkg: Package, report: MissingDepReport): Promise { const jsonPath = join(process.cwd(), pkg.location, 'package.json') const packageJson = JSON.parse(await readFile(jsonPath, { encoding: 'utf-8' })) // Only fix @hanzogui/* packages const hanzoguiDeps = report.missingDeps.filter((dep) => dep.startsWith('@hanzogui/')) if (hanzoguiDeps.length === 0) { return } // Add missing @hanzogui/* packages to dependencies with workspace:* version packageJson.dependencies = packageJson.dependencies || {} for (const dep of hanzoguiDeps) { packageJson.dependencies[dep] = 'workspace:*' } await writeFile(jsonPath, JSON.stringify(packageJson, null, 2) + '\n', { encoding: 'utf-8', }) console.info(` Added ${hanzoguiDeps.length} @hanzogui/* dependencies to ${pkg.name}`) } interface DependencyInfo { name: string section: 'dependencies' | 'peerDependencies' | 'devDependencies' version: string count: number } async function collectExistingDependencies( packages: Package[] ): Promise> { const depMap = new Map>() for (const pkg of packages) { const packageJson = await getPackageJson(pkg.location) if (!packageJson) continue const sections = [ { name: 'dependencies', deps: packageJson.dependencies || {} }, { name: 'peerDependencies', deps: packageJson.peerDependencies || {} }, { name: 'devDependencies', deps: packageJson.devDependencies || {} }, ] as const for (const section of sections) { for (const [depName, version] of Object.entries(section.deps)) { if (!depMap.has(depName)) { depMap.set(depName, new Map()) } const sectionMap = depMap.get(depName)! const key = `${section.name}:${version}` const existing = sectionMap.get(key) || { version, count: 0 } sectionMap.set(key, { version, count: existing.count + 1 }) } } } // Convert to final format with most common section/version const result = new Map() for (const [depName, sectionMap] of depMap.entries()) { let bestMatch: DependencyInfo | null = null let maxCount = 0 for (const [key, info] of sectionMap.entries()) { if (info.count > maxCount) { maxCount = info.count const [section, version] = key.split(':') bestMatch = { name: depName, section: section as 'dependencies' | 'peerDependencies' | 'devDependencies', version, count: info.count, } } } if (bestMatch) { result.set(depName, bestMatch) } } return result } async function fixAllDependencies( pkg: Package, report: MissingDepReport, existingDeps: Map ): Promise { const jsonPath = join(process.cwd(), pkg.location, 'package.json') const packageJson = JSON.parse(await readFile(jsonPath, { encoding: 'utf-8' })) let changesCount = 0 // Initialize sections if they don't exist packageJson.dependencies = packageJson.dependencies || {} packageJson.peerDependencies = packageJson.peerDependencies || {} packageJson.devDependencies = packageJson.devDependencies || {} const reactNativeVersion = await getReactNativeVersion() for (const dep of report.missingDeps) { // Special handling for specific dependencies if (dep === 'react' || dep === 'react-dom') { // Put react/react-dom in both peerDependencies and devDependencies with "*" packageJson.peerDependencies[dep] = '*' packageJson.devDependencies[dep] = '*' changesCount++ } else if (dep === 'react-native') { // Put react-native in both peerDependencies and devDependencies packageJson.peerDependencies[dep] = reactNativeVersion packageJson.devDependencies[dep] = reactNativeVersion changesCount++ } else if (dep.startsWith('@hanzogui/') || dep === 'hanzogui') { // Check if dependency is already in devDependencies if (packageJson.devDependencies[dep]) { // Move from devDependencies to dependencies packageJson.dependencies[dep] = packageJson.devDependencies[dep] delete packageJson.devDependencies[dep] changesCount++ } else { // Fix @hanzogui/* packages and "hanzogui" with workspace:* packageJson.dependencies[dep] = 'workspace:*' changesCount++ } } else if (existingDeps.has(dep)) { // Use existing dependency info from other packages const depInfo = existingDeps.get(dep)! const section = depInfo.section // Only add if it's not already in any section if ( !packageJson.dependencies[dep] && !packageJson.peerDependencies[dep] && !packageJson.devDependencies[dep] ) { packageJson[section][dep] = depInfo.version changesCount++ } } // Skip all other dependencies } if (changesCount > 0) { await writeFile(jsonPath, JSON.stringify(packageJson, null, 2) + '\n', { encoding: 'utf-8', }) console.info(` Fixed ${changesCount} dependencies in ${pkg.name}`) } } async function main() { const args = process.argv.slice(2) const fixGui = args.includes('--fix-hanzogui') const fixAll = args.includes('--fix') console.info('Analyzing package dependencies...\n') // console.info('[DEBUG] Finding all packages...') const allPackages = await findAllPackages() // console.info('[DEBUG] Found all packages, filtering...') const packages = allPackages.filter((pkg) => pkg.name !== '@hanzogui/bento') console.info(`Found ${packages.length} packages to analyze (excluding @hanzogui/bento)`) // console.info('[DEBUG] Starting scanAllImports...') const reports = await pMap( packages, async (pkg) => { try { return await analyzePackage(pkg) } catch (err: any) { console.error(`Error analyzing ${pkg.name}:`, err.message) return null } }, { concurrency: 5 } ) const validReports = reports.filter(Boolean) as MissingDepReport[] if (validReports.length === 0) { console.info('\nNo missing dependencies found!') return } console.info(`\nMISSING DEPENDENCIES REPORT`) console.info(`${'='.repeat(50)}`) console.info( `Found ${validReports.length} package(s) with potentially missing dependencies:\n` ) // Apply fixes if requested if (fixAll) { console.info('Fixing all dependencies...\n') // Collect existing dependencies from all packages const existingDeps = await collectExistingDependencies(packages) await pMap( validReports, async (report) => { const pkg = packages.find((p) => p.name === report.packageName) if (pkg) { await fixAllDependencies(pkg, report, existingDeps) } }, { concurrency: 3 } ) console.info('\nFixed all dependencies!') console.info('Re-analyzing after fixes...\n') // Re-analyze to show updated results const newReports = await pMap( packages, async (pkg) => { try { return await analyzePackage(pkg) } catch { return null } }, { concurrency: 5 } ) const newValidReports = newReports.filter(Boolean) as MissingDepReport[] if (newValidReports.length === 0) { console.info('All dependencies fixed!') return } console.info(`REMAINING MISSING DEPENDENCIES`) console.info(`${'='.repeat(50)}`) console.info( `Found ${newValidReports.length} package(s) with remaining missing dependencies:\n` ) newValidReports.forEach((report, index) => { console.info(`${index + 1}. ${report.packageName}`) console.info(` Location: ${report.location}`) console.info(` Missing dependencies:`) report.missingDeps.forEach((dep) => { console.info(` - ${dep}`) }) console.info() }) // Exit with code 1 since there are still missing dependencies process.exit(1) } else if (fixGui) { console.info('Fixing @hanzogui/* dependencies...\n') await pMap( validReports, async (report) => { const pkg = packages.find((p) => p.name === report.packageName) if (pkg) { await fixGuiDependencies(pkg, report) } }, { concurrency: 3 } ) console.info('\nFixed @hanzogui/* dependencies!') console.info('Re-analyzing after fixes...\n') // Re-analyze to show updated results const newReports = await pMap( packages, async (pkg) => { try { return await analyzePackage(pkg) } catch { return null } }, { concurrency: 5 } ) const newValidReports = newReports.filter(Boolean) as MissingDepReport[] if (newValidReports.length === 0) { console.info('All @hanzogui/* dependencies fixed!') return } console.info(`REMAINING MISSING DEPENDENCIES`) console.info(`${'='.repeat(50)}`) console.info( `Found ${newValidReports.length} package(s) with remaining missing dependencies:\n` ) newValidReports.forEach((report, index) => { console.info(`${index + 1}. ${report.packageName}`) console.info(` Location: ${report.location}`) console.info(` Missing dependencies:`) report.missingDeps.forEach((dep) => { console.info(` - ${dep}`) }) console.info() }) // Exit with code 1 since there are still missing dependencies process.exit(1) } else { validReports.forEach((report, index) => { console.info(`${index + 1}. ${report.packageName}`) console.info(` Location: ${report.location}`) console.info(` Missing dependencies:`) report.missingDeps.forEach((dep) => { console.info(` - ${dep}`) }) console.info() }) } console.info( 'Note: This may include false positives for built-in modules, type-only imports, or monorepo packages.' ) if (!fixGui && !fixAll) { console.info( '\nUse --fix-hanzogui to automatically add missing @hanzogui/* dependencies' ) console.info('Use --fix to automatically fix all dependencies') // Exit with code 1 to indicate missing dependencies were found process.exit(1) } } main().catch(console.error)