#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const jsonc = require('jsonc-parser'); const { execSync } = require('child_process'); /** * Smart lockfile drift detector that focuses on actionable updates * and avoids API rate limits by using yarn's built-in commands where possible */ class LockfileDriftDetector { constructor() { this.workspaces = []; this.directDeps = new Map(); this.outdatedInfo = []; this.workspaceStats = new Map(); this.workspaceDepsCount = new Map(); this.ignoredWorkspaceDeps = new Set(); this.renovateIgnoredDeps = new Set(); // Parse command line arguments this.args = process.argv.slice(2); this.filterSeverity = null; // Check for severity filters if (this.args.includes('--patch')) { this.filterSeverity = 'patch'; } else if (this.args.includes('--minor')) { this.filterSeverity = 'minor'; } else if (this.args.includes('--major')) { this.filterSeverity = 'major'; } // Check for help flag if (this.args.includes('--help') || this.args.includes('-h')) { this.showHelp(); process.exit(0); } } /** * Show help message */ showHelp() { console.log(` Dependency Inspector - Smart lockfile drift detector Usage: dependency-inspector.js [options] Options: --patch Show all packages with patch updates --minor Show all packages with minor updates --major Show all packages with major updates --help, -h Show this help message Without flags, shows high-priority updates sorted by impact. With a severity flag, shows all packages with that update type. `); } /** * Load ignored dependencies from renovate configuration */ loadRenovateConfig() { console.log('šŸ”§ Loading renovate configuration...'); try { // Read renovate.json from project root (two levels up from .github/scripts/) const renovateConfigPath = path.join(__dirname, '../../.github/renovate.json5'); const renovateConfig = jsonc.parse(fs.readFileSync(renovateConfigPath, 'utf8')); if (renovateConfig.ignoreDeps) { for (const dep of renovateConfig.ignoreDeps) { this.renovateIgnoredDeps.add(dep); } console.log(`šŸ“ Loaded ${renovateConfig.ignoreDeps.length} ignored dependencies from renovate.json`); console.log(` Ignored: ${Array.from(this.renovateIgnoredDeps).join(', ')}`); } else { console.log('šŸ“ No ignoreDeps found in renovate.json'); } } catch (error) { console.warn('āš ļø Could not load renovate.json:', error.message); } } /** * Get all workspace package.json files */ async findWorkspaces() { // Read from project root (two levels up from .github/scripts/) const rootDir = path.join(__dirname, '../..'); const rootPackage = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')); const workspacePatterns = rootPackage.workspaces || []; console.log('šŸ“¦ Scanning workspaces...'); // Add root package this.workspaces.push({ name: rootPackage.name || 'root', path: '.', packageJson: rootPackage }); // Find workspace packages for (const pattern of workspacePatterns) { const globPattern = path.join(rootDir, pattern.replace(/\*$/, '')); try { const dirs = fs.readdirSync(globPattern, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => path.join(globPattern, dirent.name)); for (const dir of dirs) { const packageJsonPath = path.join(dir, 'package.json'); if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Skip ghost/admin directory but track its dependencies for filtering if (path.basename(dir) === 'admin' && dir.includes('ghost')) { console.log(`🚫 Ignoring ghost/admin workspace (tracking deps for filtering)`); const deps = { ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies, ...packageJson.optionalDependencies }; // Add all ghost/admin dependencies to ignore list for (const depName of Object.keys(deps || {})) { this.ignoredWorkspaceDeps.add(depName); } continue; } this.workspaces.push({ name: packageJson.name || path.basename(dir), path: dir, packageJson }); } catch (e) { console.warn(`āš ļø Skipped ${packageJsonPath}: ${e.message}`); } } } } catch (e) { console.warn(`āš ļø Skipped pattern ${pattern}: ${e.message}`); } } console.log(`Found ${this.workspaces.length} workspaces`); return this.workspaces; } /** * Extract all direct dependencies from workspaces */ extractDirectDependencies() { console.log('šŸ” Extracting direct dependencies...'); for (const workspace of this.workspaces) { const { packageJson } = workspace; const deps = { ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies, ...packageJson.optionalDependencies }; // Count total dependencies for this workspace const totalDepsForWorkspace = Object.keys(deps || {}).length; this.workspaceDepsCount.set(workspace.name, totalDepsForWorkspace); for (const [name, range] of Object.entries(deps || {})) { if (!this.directDeps.has(name)) { this.directDeps.set(name, new Set()); } this.directDeps.get(name).add({ workspace: workspace.name, range, path: workspace.path }); } } return this.directDeps; } /** * Use yarn outdated to get comprehensive outdated info * This is much faster and more reliable than manual API calls */ async getOutdatedPackages() { console.log('šŸ”„ Running yarn outdated (this may take a moment)...'); try { // yarn outdated returns non-zero exit code when packages are outdated // so we need to handle that const result = execSync('yarn outdated --json', { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large output }); const lines = result.trim().split('\n'); const outdatedData = []; for (const line of lines) { try { const data = JSON.parse(line); if (data.type === 'table' && data.data && data.data.body) { outdatedData.push(...data.data.body); } } catch (e) { // Skip non-JSON lines } } return outdatedData; } catch (error) { // yarn outdated exits with code 1 when there are outdated packages if (error.status === 1 && error.stdout) { const lines = error.stdout.trim().split('\n'); const outdatedData = []; for (const line of lines) { try { const data = JSON.parse(line); if (data.type === 'table' && data.data && data.data.body) { outdatedData.push(...data.data.body); } } catch (e) { // Skip non-JSON lines } } return outdatedData; } else { console.error('Failed to run yarn outdated:', error.message); return []; } } } /** * Analyze the severity of version differences */ analyzeVersionDrift(current, wanted, latest) { const parseVersion = (v) => { const match = v.match(/(\d+)\.(\d+)\.(\d+)/); if (!match) return { major: 0, minor: 0, patch: 0 }; return { major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]) }; }; const currentVer = parseVersion(current); const latestVer = parseVersion(latest); const majorDiff = latestVer.major - currentVer.major; const minorDiff = latestVer.minor - currentVer.minor; const patchDiff = latestVer.patch - currentVer.patch; let severity = 'patch'; let score = patchDiff; if (majorDiff > 0) { severity = 'major'; score = majorDiff * 1000 + minorDiff * 100 + patchDiff; } else if (minorDiff > 0) { severity = 'minor'; score = minorDiff * 100 + patchDiff; } return { severity, score, majorDiff, minorDiff, patchDiff }; } /** * Process and categorize outdated packages */ processOutdatedPackages(outdatedData) { console.log('šŸ“Š Processing outdated package information...'); // Initialize workspace stats for (const workspace of this.workspaces) { this.workspaceStats.set(workspace.name, { total: 0, major: 0, minor: 0, patch: 0, packages: [], outdatedPackageNames: new Set() // Track unique package names per workspace }); } const results = { direct: [], transitive: [], stats: { total: 0, major: 0, minor: 0, patch: 0 } }; for (const [packageName, current, wanted, latest, packageType] of outdatedData) { const isDirect = this.directDeps.has(packageName); // Skip packages that are only used by ignored workspaces (like ghost/admin) if (!isDirect && this.ignoredWorkspaceDeps.has(packageName)) { continue; } // Skip packages that are ignored by renovate configuration if (this.renovateIgnoredDeps.has(packageName)) { continue; } const analysis = this.analyzeVersionDrift(current, wanted, latest); const packageInfo = { name: packageName, current, wanted, latest, type: packageType || 'dependencies', isDirect, ...analysis, workspaces: isDirect ? Array.from(this.directDeps.get(packageName)) : [] }; // Update workspace statistics for direct dependencies if (isDirect) { for (const workspaceInfo of packageInfo.workspaces) { const stats = this.workspaceStats.get(workspaceInfo.workspace); if (stats && !stats.outdatedPackageNames.has(packageName)) { // Only count each package once per workspace stats.outdatedPackageNames.add(packageName); stats.total++; stats[analysis.severity]++; stats.packages.push({ name: packageName, current, latest, severity: analysis.severity }); } } results.direct.push(packageInfo); } else { results.transitive.push(packageInfo); } results.stats.total++; results.stats[analysis.severity]++; } // Deduplicate direct dependencies and count workspace impact const directDepsMap = new Map(); for (const pkg of results.direct) { if (!directDepsMap.has(pkg.name)) { directDepsMap.set(pkg.name, { ...pkg, workspaceCount: pkg.workspaces.length, impact: pkg.workspaces.length // Number of workspaces affected }); } } // Sort by impact: workspace count first, then severity, then score const sortByImpact = (a, b) => { // First by number of workspaces (more workspaces = higher priority) if (a.impact !== b.impact) { return b.impact - a.impact; } // Then by severity if (a.severity !== b.severity) { const severityOrder = { major: 3, minor: 2, patch: 1 }; return severityOrder[b.severity] - severityOrder[a.severity]; } // Finally by version drift score return b.score - a.score; }; results.direct = Array.from(directDepsMap.values()).sort(sortByImpact); results.transitive.sort((a, b) => { if (a.severity !== b.severity) { const severityOrder = { major: 3, minor: 2, patch: 1 }; return severityOrder[b.severity] - severityOrder[a.severity]; } return b.score - a.score; }); return results; } /** * Display filtered results by severity */ displayFilteredResults(results) { const severityEmoji = { major: 'šŸ”“', minor: '🟔', patch: '🟢' }; const emoji = severityEmoji[this.filterSeverity]; const filterTitle = this.filterSeverity.toUpperCase(); console.log(`${emoji} ${filterTitle} UPDATES ONLY:\n`); // Filter direct dependencies const filteredDirect = results.direct.filter(pkg => pkg.severity === this.filterSeverity); const filteredTransitive = results.transitive.filter(pkg => pkg.severity === this.filterSeverity); console.log(`Found ${filteredDirect.length} direct and ${filteredTransitive.length} transitive ${this.filterSeverity} updates.\n`); if (filteredDirect.length > 0) { console.log('šŸ“¦ DIRECT DEPENDENCIES:'); console.log('─'.repeat(80)); // Sort by workspace impact, then by package name filteredDirect.sort((a, b) => { if (a.impact !== b.impact) { return b.impact - a.impact; } return a.name.localeCompare(b.name); }); for (const pkg of filteredDirect) { const workspaceList = pkg.workspaces.map(w => w.workspace).join(', '); const impactNote = pkg.workspaceCount > 1 ? ` (${pkg.workspaceCount} workspaces)` : ''; console.log(` ${emoji} ${pkg.name}: ${pkg.current} → ${pkg.latest}${impactNote}`); console.log(` Workspaces: ${workspaceList}`); } console.log('\nšŸš€ UPDATE COMMANDS:'); console.log('─'.repeat(80)); for (const pkg of filteredDirect) { console.log(` yarn upgrade ${pkg.name}@latest`); } } if (filteredTransitive.length > 0) { console.log('\n\nšŸ”„ TRANSITIVE DEPENDENCIES:'); console.log('─'.repeat(80)); console.log(' These will likely be updated automatically when you update direct deps.\n'); // Sort by package name for easier scanning filteredTransitive.sort((a, b) => a.name.localeCompare(b.name)); for (const pkg of filteredTransitive) { console.log(` ${emoji} ${pkg.name}: ${pkg.current} → ${pkg.latest}`); } } // Show workspace-specific breakdown console.log('\n\nšŸ¢ WORKSPACE BREAKDOWN:'); console.log('─'.repeat(80)); for (const [workspaceName, stats] of this.workspaceStats.entries()) { const severityCount = stats[this.filterSeverity]; if (severityCount > 0) { const packages = stats.packages.filter(p => p.severity === this.filterSeverity); console.log(`\n šŸ“¦ ${workspaceName}: ${severityCount} ${this.filterSeverity} update${severityCount !== 1 ? 's' : ''}`); // Show all packages for this workspace with the selected severity for (const pkg of packages) { console.log(` ${emoji} ${pkg.name}: ${pkg.current} → ${pkg.latest}`); } } } console.log(''); } /** * Display results in a helpful format */ displayResults(results) { console.log('\nšŸŽÆ DEPENDENCY ANALYSIS RESULTS\n'); // Summary console.log('šŸ“ˆ SUMMARY:'); console.log(` Total dependencies: ${this.directDeps.size}`); console.log(` Total outdated: ${results.stats.total}`); console.log(` Major updates: ${results.stats.major}`); console.log(` Minor updates: ${results.stats.minor}`); console.log(` Patch updates: ${results.stats.patch}`); console.log(` Direct deps: ${results.direct.length}`); console.log(` Transitive deps: ${results.transitive.length}\n`); // If filtering by severity, show filtered results if (this.filterSeverity) { this.displayFilteredResults(results); return; } // Workspace-specific statistics console.log('šŸ¢ WORKSPACE BREAKDOWN:'); console.log(' Outdated packages per workspace:\n'); // Sort workspaces by percentage of outdated packages (descending), then by total count const sortedWorkspaces = Array.from(this.workspaceStats.entries()) .sort(([nameA, a], [nameB, b]) => { const totalA = this.workspaceDepsCount.get(nameA) || 0; const totalB = this.workspaceDepsCount.get(nameB) || 0; const percentageA = totalA > 0 ? (a.total / totalA) * 100 : 0; const percentageB = totalB > 0 ? (b.total / totalB) * 100 : 0; // Sort by percentage first, then by total count if (Math.abs(percentageA - percentageB) > 0.1) { return percentageB - percentageA; } return b.total - a.total; }); for (const [workspaceName, stats] of sortedWorkspaces) { const totalDeps = this.workspaceDepsCount.get(workspaceName) || 0; const outdatedCount = stats.total; const percentage = totalDeps > 0 ? ((outdatedCount / totalDeps) * 100).toFixed(1) : '0.0'; if (stats.total === 0) { console.log(` āœ… ${workspaceName}: All ${totalDeps} dependencies up to date! (0% outdated)`); } else { console.log(` šŸ“¦ ${workspaceName}: ${outdatedCount}/${totalDeps} outdated (${percentage}%)`); console.log(` šŸ”“ Major: ${stats.major} | 🟔 Minor: ${stats.minor} | 🟢 Patch: ${stats.patch}`); // Show top 3 most outdated packages for this workspace const topPackages = stats.packages .sort((a, b) => { const severityOrder = { major: 3, minor: 2, patch: 1 }; return severityOrder[b.severity] - severityOrder[a.severity]; }) .slice(0, 3); if (topPackages.length > 0) { console.log(` Top issues: ${topPackages.map(p => { const emoji = p.severity === 'major' ? 'šŸ”“' : p.severity === 'minor' ? '🟔' : '🟢'; return `${emoji} ${p.name} (${p.current}→${p.latest})`; }).join(', ')}`); } console.log(''); } } console.log(''); // Direct dependencies (most actionable) if (results.direct.length > 0) { console.log('šŸŽÆ DIRECT DEPENDENCIES (High Priority):'); console.log(' Sorted by impact: workspace count → severity → version drift\n'); const topDirect = results.direct.slice(0, 15); for (const pkg of topDirect) { const emoji = pkg.severity === 'major' ? 'šŸ”“' : pkg.severity === 'minor' ? '🟔' : '🟢'; const impactEmoji = pkg.workspaceCount >= 5 ? '🌟' : pkg.workspaceCount >= 3 ? '⭐' : ''; console.log(` ${emoji} ${impactEmoji} ${pkg.name}`); console.log(` ${pkg.current} → ${pkg.latest} (${pkg.severity})`); console.log(` Used in ${pkg.workspaceCount} workspace${pkg.workspaceCount !== 1 ? 's' : ''}: ${pkg.workspaces.map(w => w.workspace).join(', ')}`); console.log(''); } if (results.direct.length > 15) { console.log(` ... and ${results.direct.length - 15} more direct dependencies\n`); } } // Sample of most outdated transitive dependencies if (results.transitive.length > 0) { console.log('šŸ”„ MOST OUTDATED TRANSITIVE DEPENDENCIES (Lower Priority):'); console.log(' These will likely be updated automatically when you update direct deps.\n'); const topTransitive = results.transitive.slice(0, 10); for (const pkg of topTransitive) { const emoji = pkg.severity === 'major' ? 'šŸ”“' : pkg.severity === 'minor' ? '🟔' : '🟢'; console.log(` ${emoji} ${pkg.name}: ${pkg.current} → ${pkg.latest} (${pkg.severity})`); } if (results.transitive.length > 10) { console.log(` ... and ${results.transitive.length - 10} more transitive dependencies\n`); } } // Generate update commands for highest impact packages const topUpdates = results.direct.slice(0, 5); if (topUpdates.length > 0) { console.log('šŸš€ SUGGESTED COMMANDS (highest impact first):'); for (const pkg of topUpdates) { const impactNote = pkg.workspaceCount > 1 ? ` (affects ${pkg.workspaceCount} workspaces)` : ''; console.log(` yarn upgrade ${pkg.name}@latest${impactNote}`); } console.log(''); } } async run() { try { // Change to project root directory to run commands correctly const rootDir = path.join(__dirname, '../..'); process.chdir(rootDir); this.loadRenovateConfig(); await this.findWorkspaces(); this.extractDirectDependencies(); const outdatedData = await this.getOutdatedPackages(); if (outdatedData.length === 0) { console.log('šŸŽ‰ All packages are up to date!'); return; } const results = this.processOutdatedPackages(outdatedData); this.displayResults(results); } catch (error) { console.error('āŒ Error:', error.message); process.exit(1); } } } // Run the detector const detector = new LockfileDriftDetector(); detector.run();