You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
629 lines
21 KiB
629 lines
21 KiB
#!/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();
|