From e522e73b7a960fdad1f318965b9ebc34df940e52 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 10 Dec 2024 20:31:14 +0200 Subject: [PATCH] Added typescript, test and feature for comma seperated packages --- bin/dep-check.js | 7 +- eslint.config.js | 101 +++- package.json | 25 +- src/index.js | 671 -------------------------- src/index.ts | 159 +++++++ src/lib.ts | 786 +++++++++++++++++++++++++++++++ tests/app/package.json | 13 + tests/dependency-checker.test.ts | 156 ++++++ tests/packages/pkg1/package.json | 11 + tests/packages/pkg2/package.json | 8 + tsconfig.json | 20 + vite.config.js | 8 +- 12 files changed, 1274 insertions(+), 691 deletions(-) delete mode 100644 src/index.js create mode 100644 src/index.ts create mode 100644 src/lib.ts create mode 100644 tests/app/package.json create mode 100644 tests/dependency-checker.test.ts create mode 100644 tests/packages/pkg1/package.json create mode 100644 tests/packages/pkg2/package.json create mode 100644 tsconfig.json diff --git a/bin/dep-check.js b/bin/dep-check.js index a2ca9bb..1cee87e 100644 --- a/bin/dep-check.js +++ b/bin/dep-check.js @@ -3,10 +3,13 @@ import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import { pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); -const { default: run } = await import(join(__dirname, '../dist/index.js')); -run().catch(console.error); +// Convert the file path to a proper file:// URL +const modulePath = pathToFileURL(join(__dirname, '../dist/index.js')).href; +const { default: run } = await import(modulePath); +run().catch(console.error); \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 6d1eeba..6479606 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,96 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; +import eslintJs from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import eslintTs from 'typescript-eslint'; -/** @type {import('eslint').Linter.Config[]} */ -export default [ - {languageOptions: { globals: globals.browser }}, - pluginJs.configs.recommended, -]; \ No newline at end of file +const tsFiles = ['{src,lib}/**/*.{ts,tsx}']; + +const languageOptions = { + globals: { + ...globals.node, + ...globals.jest, + }, + ecmaVersion: 2023, + sourceType: 'module', +}; + +const rules = { + '@typescript-eslint/no-use-before-define': 'off', + 'require-await': 'off', + 'no-duplicate-imports': 'error', + 'no-unneeded-ternary': 'error', + 'prefer-object-spread': 'error', + '@typescript-eslint/array-type': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true, + args: 'none', + }, + ], +}; + +const commonIgnores = [ + 'node_modules', + '**/node_modules', + '**/dist/*', + 'docs/*', + 'build/*', + 'dist/*', + 'next.config.mjs', +]; + +const customTypescriptConfig = { + files: tsFiles, + plugins: { + 'import/parsers': tsParser, + }, + languageOptions: { + ...languageOptions, + parser: tsParser, + parserOptions: { + project: './tsconfig.json', + sourceType: 'module', + }, + }, + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts'], + }, + }, + rules: { + + ...rules, + }, + ignores: commonIgnores, +}; + +// Add the files for applying the recommended TypeScript configs +// only for the Typescript files. +// This is necessary when we have the multiple extensions files +// (e.g. .ts, .tsx, .js, .cjs, .mjs, etc.). +const recommendedTypeScriptConfigs = [ + ...eslintTs.configs.recommended.map((config) => ({ + ...config, + files: tsFiles, + rules: { + ...config.rules, + ...rules, + }, + ignores: commonIgnores, + })), + ...eslintTs.configs.stylistic.map((config) => ({ + ...config, + files: tsFiles, + rules: { + ...config.rules, + ...rules, + }, + ignores: commonIgnores, + })), +]; + +export default [...recommendedTypeScriptConfigs, customTypescriptConfig]; diff --git a/package.json b/package.json index ec6b691..64d2e95 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "scripts": { "build": "vite build", "prepublishOnly": "npm run build", - "test": "vitest run", - "lint": "eslint src" + "test": "vitest run --silent=false", + "lint": "eslint ./src" }, "keywords": [ "monorepo", @@ -42,11 +42,17 @@ "yargs": "^17.7.2" }, "devDependencies": { - "@eslint/js": "^9.15.0", - "eslint": "^8.57.1", - "globals": "^15.12.0", - "vite": "^5.0.0", - "vitest": "^1.0.0" + "@eslint/js": "^9.16.0", + "@types/node": "^22.10.1", + "@types/semver": "~7.5.8", + "@types/yargs": "~17.0.33", + "eslint": "^9.16.0", + "globals": "^15.13.0", + "prettier-eslint": "^16.3.0", + "typescript-eslint": "^8.18.0", + "typesync": "^0.14.0", + "vite": "^5.4.11", + "vitest": "^1.6.0" }, "engines": { "node": ">=14.16" @@ -58,5 +64,6 @@ "bugs": { "url": "https://github.com/warkanum/monorepo-dep-checker/issues" }, - "homepage": "https://github.com/warkanum/monorepo-dep-checker#readme" -} \ No newline at end of file + "homepage": "https://github.com/warkanum/monorepo-dep-checker#readme", + "packageManager": "pnpm@9.6.0+sha256.dae0f7e822c56b20979bb5965e3b73b8bdabb6b8b8ef121da6d857508599ca35" +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 46c8e9b..0000000 --- a/src/index.js +++ /dev/null @@ -1,671 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import chalk from 'chalk'; -import semver from 'semver'; -import yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; - -class DependencyChecker { - constructor(appPackageJsonPath, packagesDir) { - this.appPackageJsonPath = appPackageJsonPath; - this.packagesDir = packagesDir; - this.packageJsonFiles = []; - this.dependencyMap = new Map(); - this.workspacePackages = new Set(); - } - - findWorkspacePackages() { - const entries = fs.readdirSync(this.packagesDir, { withFileTypes: true }); - - entries.forEach((entry) => { - if (entry.isDirectory()) { - const packageJsonPath = path.join(this.packagesDir, entry.name, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - this.workspacePackages.add(packageJson.name); - } - } - }); - } - - isWorkspaceDep(version) { - return version?.startsWith('workspace:') || version === '*'; - } - - isWorkspacePackage(packageName) { - return this.workspacePackages.has(packageName); - } - - shouldIncludeDependency(depName, version, packageJson) { - // Skip if it's a workspace dependency - if (this.isWorkspaceDep(version)) return false; - - // Skip if the dependency is a workspace package - if (this.isWorkspacePackage(depName)) return false; - - // Skip if the package itself is referenced as a workspace dependency - const allWorkspaceDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.peerDependencies, - }; - - return !Object.entries(allWorkspaceDeps).some( - ([name, ver]) => name === depName && this.isWorkspaceDep(ver) - ); - } - - getSemverVersion(version) { - // Handle workspace protocol - if (version.startsWith('workspace:')) { - // Extract actual version from the workspace package - const workspaceVersion = version.replace('workspace:', ''); - if (workspaceVersion === '*') return null; - // If it's a specific version after workspace:, use that - if (semver.valid(workspaceVersion)) return workspaceVersion; - return null; - } - - // Handle other special cases - if (version === '*' || version === 'latest') return null; - - // Remove any leading special characters (^, ~, etc) - const cleanVersion = version.replace(/^[~^]/, ''); - - // Try to parse as semver - try { - if (semver.valid(cleanVersion)) return cleanVersion; - return null; - } catch { - return null; - } - } - - checkMissingDependencies() { - console.log(chalk.bold('\nChecking for missing dependencies...\n')); - - this.findWorkspacePackages(); - - const appPackageJson = JSON.parse(fs.readFileSync(this.appPackageJsonPath, 'utf8')); - const appDeps = { - ...appPackageJson.dependencies, - ...appPackageJson.peerDependencies, - }; - - const missingDeps = new Map(); - const uniqueMissingDeps = new Set(); - const uniqueExtraDeps = new Set(); - - this.packageJsonFiles - .filter((file) => file !== this.appPackageJsonPath) - .forEach((filePath) => { - const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8')); - const packageName = packageJson.name; - const packageDeps = { - ...packageJson.dependencies, - ...packageJson.peerDependencies, - }; - - const missing = []; - const extraDeps = []; - - Object.entries(packageDeps).forEach(([dep, version]) => { - if (!appDeps[dep] && this.shouldIncludeDependency(dep, version, packageJson)) { - missing.push({ - name: dep, - version, - }); - uniqueMissingDeps.add(dep); - } - }); - - Object.entries(appDeps).forEach(([dep, version]) => { - if (!packageDeps[dep] && this.shouldIncludeDependency(dep, version, appPackageJson)) { - extraDeps.push({ - name: dep, - version, - }); - uniqueExtraDeps.add(dep); - } - }); - - if (missing.length > 0 || extraDeps.length > 0) { - missingDeps.set(packageName, { - // eslint-disable-next-line no-undef - path: path.relative(process.cwd(), filePath), - missing, - extraDeps, - }); - } - }); - - if (missingDeps.size === 0) { - console.log(chalk.green('✓ All dependencies are properly synchronized\n')); - return; - } - - missingDeps.forEach(({ path: pkgPath, missing, extraDeps }, packageName) => { - if (missing.length > 0 || extraDeps.length > 0) { - console.log(chalk.yellow(`\n${packageName} (${chalk.gray(pkgPath)}):`)); - - if (missing.length > 0) { - console.log(chalk.red(' Missing from main app:')); - missing.forEach(({ name, version }) => { - console.log(` - ${name}@${version}`); - }); - } - - if (extraDeps.length > 0) { - console.log(chalk.blue(' Not used by package but in main app:')); - extraDeps.forEach(({ name, version }) => { - console.log(` - ${name}@${version}`); - }); - } - } - }); - - if (uniqueMissingDeps.size > 0 || uniqueExtraDeps.size > 0) { - console.log(chalk.bold('\nSummary:')); - console.log(`Packages with dependency mismatches: ${missingDeps.size}`); - if (uniqueMissingDeps.size > 0) { - console.log( - chalk.red(`Unique dependencies missing from main app: ${uniqueMissingDeps.size}`) - ); - console.log(chalk.gray(' ' + Array.from(uniqueMissingDeps).join(', '))); - } - if (uniqueExtraDeps.size > 0) { - console.log( - chalk.blue(`Unique unused dependencies from main app: ${uniqueExtraDeps.size}`) - ); - console.log(chalk.gray(' ' + Array.from(uniqueExtraDeps).join(', '))); - } - } - } - - compareDependencyVersions(version1, version2) { - const v1 = this.getSemverVersion(version1); - const v2 = this.getSemverVersion(version2); - - // If either version can't be parsed, return 'unknown' - if (!v1 || !v2) return 'unknown'; - - try { - return semver.diff(v1, v2); - } catch { - return 'unknown'; - } - } - - getVersionLabel(version) { - if (version.startsWith('workspace:')) { - return `${chalk.cyan('workspace:')}${version.slice(10)}`; - } - return version; - } - - findPackageJsonFiles() { - this.packageJsonFiles.push(this.appPackageJsonPath); - - const entries = fs.readdirSync(this.packagesDir, { withFileTypes: true }); - - entries.forEach((entry) => { - if (entry.isDirectory()) { - const packageJsonPath = path.join(this.packagesDir, entry.name, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - this.packageJsonFiles.push(packageJsonPath); - } - } - }); - } - - analyzeDependencies() { - this.packageJsonFiles.forEach((filePath) => { - const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8')); - const normalDeps = packageJson.dependencies || {}; - const peerDeps = packageJson.peerDependencies || {}; - const packageName = packageJson.name; - const isMainApp = filePath === this.appPackageJsonPath; - - [ - { deps: normalDeps, type: 'normal' }, - { deps: peerDeps, type: 'peer' }, - ].forEach(({ deps, type }) => { - Object.entries(deps).forEach(([dep, version]) => { - if (!this.dependencyMap.has(dep)) { - this.dependencyMap.set(dep, { - versions: new Map(), - usedAsNormal: false, - usedAsPeer: false, - }); - } - - const depInfo = this.dependencyMap.get(dep); - if (!depInfo.versions.has(version)) { - depInfo.versions.set(version, { - packages: new Set(), - usages: new Set(), - }); - } - - const versionInfo = depInfo.versions.get(version); - versionInfo.packages.add(packageName); - versionInfo.usages.add(`${type}${isMainApp ? ' (main app)' : ''}`); - - if (type === 'normal') depInfo.usedAsNormal = true; - if (type === 'peer') depInfo.usedAsPeer = true; - }); - }); - }); - } - - displayVersionDifferences() { - console.log(chalk.bold('\nVersion Differences Summary:\n')); - - let hasDifferences = false; - const conflictingDeps = []; - - this.dependencyMap.forEach((depInfo, dep) => { - if (depInfo.versions.size > 1) { - hasDifferences = true; - const versions = Array.from(depInfo.versions.entries()).map(([version, info]) => ({ - version, - packages: Array.from(info.packages), - paths: Array.from(info.packages).map((pkg) => { - const filePath = this.packageJsonFiles.find((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - return json.name === pkg; - }); - return { - package: pkg, - // eslint-disable-next-line no-undef - path: path.relative(process.cwd(), filePath), - }; - }), - usages: Array.from(info.usages), - })); - - // Create comparison matrix - const comparisons = []; - for (let i = 0; i < versions.length; i++) { - for (let j = i + 1; j < versions.length; j++) { - const v1 = versions[i]; - const v2 = versions[j]; - const comparison = { - version1: v1.version, - version2: v2.version, - packages1: v1.paths, - packages2: v2.paths, - semverDiff: this.compareDependencyVersions(v1.version, v2.version), - }; - comparisons.push(comparison); - } - } - - conflictingDeps.push({ - name: dep, - versions, - comparisons, - }); - } - }); - - if (!hasDifferences) { - console.log(chalk.green('✓ No version differences found across packages\n')); - return; - } - - conflictingDeps.forEach(({ name, comparisons }) => { - console.log(chalk.yellow(`\n${name}:`)); - - comparisons.forEach(({ version1, version2, packages1, packages2, semverDiff }) => { - console.log(chalk.cyan(`\n Difference (${semverDiff || 'unknown'}):`)); - console.log(` ${this.getVersionLabel(version1)} vs ${this.getVersionLabel(version2)}`); - - console.log('\n Packages using', chalk.green(this.getVersionLabel(version1)), ':'); - packages1.forEach(({ package: pkg, path: filePath }) => { - console.log(` - ${chalk.blue(pkg)} (${chalk.gray(filePath)})`); - }); - - console.log('\n Packages using', chalk.green(this.getVersionLabel(version2)), ':'); - packages2.forEach(({ package: pkg, path: filePath }) => { - console.log(` - ${chalk.blue(pkg)} (${chalk.gray(filePath)})`); - }); - - // Suggest recommended action based on version types - console.log('\n Recommended action:'); - if (version1.startsWith('workspace:') || version2.startsWith('workspace:')) { - console.log(chalk.blue(' ℹ️ Workspace dependency - No action needed')); - } else { - switch (semverDiff) { - case 'major': - console.log( - chalk.red(' ⚠️ Major version difference - Manual review recommended') - ); - break; - case 'minor': - console.log( - chalk.yellow(' ℹ️ Minor version difference - Consider updating to latest') - ); - break; - case 'patch': - console.log(chalk.green(' ✓ Patch version difference - Safe to update to latest')); - break; - default: - console.log(chalk.gray(' ℹ️ Version difference analysis not available')); - } - } - }); - }); - - // Print total counts - console.log(chalk.bold('\nSummary Statistics:')); - const totalComparisons = conflictingDeps.reduce((acc, dep) => acc + dep.comparisons.length, 0); - console.log(`Dependencies with conflicts: ${conflictingDeps.length}`); - console.log(`Total version comparisons: ${totalComparisons}`); - - // Show severity breakdown - const severityCount = conflictingDeps.reduce((acc, dep) => { - dep.comparisons.forEach(({ version1, version2, semverDiff }) => { - if (version1.startsWith('workspace:') || version2.startsWith('workspace:')) { - acc.workspace = (acc.workspace || 0) + 1; - } else { - acc[semverDiff || 'unknown'] = (acc[semverDiff || 'unknown'] || 0) + 1; - } - }); - return acc; - }, {}); - - console.log('\nSeverity breakdown:'); - if (severityCount.workspace) - console.log(chalk.blue(` Workspace dependencies: ${severityCount.workspace}`)); - if (severityCount.major) console.log(chalk.red(` Major differences: ${severityCount.major}`)); - if (severityCount.minor) - console.log(chalk.yellow(` Minor differences: ${severityCount.minor}`)); - if (severityCount.patch) - console.log(chalk.green(` Patch differences: ${severityCount.patch}`)); - if (severityCount.unknown) - console.log(chalk.gray(` Unknown differences: ${severityCount.unknown}`)); - } - - displayResults(format = 'text') { - // First show the version differences summary - if (format === 'text') { - this.displayVersionDifferences(); - } - - // Then show the full analysis - if (format === 'json') { - const output = { - summary: { - conflicts: Array.from(this.dependencyMap.entries()) - .filter(([, depInfo]) => depInfo.versions.size > 1) - .map(([dep, depInfo]) => ({ - name: dep, - versions: Array.from(depInfo.versions.entries()).map(([version, info]) => ({ - version, - packages: Array.from(info.packages), - paths: Array.from(info.packages).map((pkg) => { - const filePath = this.packageJsonFiles.find((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - return json.name === pkg; - }); - return { - package: pkg, - // eslint-disable-next-line no-undef - path: path.relative(process.cwd(), filePath), - }; - }), - usages: Array.from(info.usages), - })), - })), - }, - fullAnalysis: {}, - }; - - this.dependencyMap.forEach((depInfo, dep) => { - output.fullAnalysis[dep] = { - versions: Object.fromEntries( - Array.from(depInfo.versions.entries()).map(([version, info]) => [ - version, - { - packages: Array.from(info.packages), - usages: Array.from(info.usages), - }, - ]) - ), - usedAsNormal: depInfo.usedAsNormal, - usedAsPeer: depInfo.usedAsPeer, - }; - }); - - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Original detailed display - console.log(chalk.bold('\nDetailed Dependencies Analysis:\n')); - - this.dependencyMap.forEach((depInfo, dep) => { - console.log(chalk.blue(`\n${dep}:`)); - - if (depInfo.versions.size > 1) { - console.log(chalk.yellow('⚠️ Multiple versions detected:')); - } - - const usageTypes = []; - if (depInfo.usedAsNormal) usageTypes.push('normal dependency'); - if (depInfo.usedAsPeer) usageTypes.push('peer dependency'); - console.log(chalk.cyan(`Used as: ${usageTypes.join(' and ')}`)); - - depInfo.versions.forEach((versionInfo, version) => { - console.log(` ${chalk.green(version)}`); - console.log(` Usage types:`); - versionInfo.usages.forEach((usage) => { - console.log(` - ${usage}`); - }); - console.log(` Packages:`); - versionInfo.packages.forEach((pkg) => { - console.log(` - ${pkg}`); - }); - }); - }); - } - - async updateDependencies(dryRun = false) { - console.log(chalk.bold('\nUpdating dependencies...\n')); - - // Read the main app's package.json - const appPackageJson = JSON.parse(fs.readFileSync(this.appPackageJsonPath, 'utf8')); - const appDependencies = { - ...appPackageJson.dependencies, - ...appPackageJson.devDependencies, - ...appPackageJson.peerDependencies - }; - - const updates = []; - - // Process each package.json except the main app - this.packageJsonFiles - .filter(filePath => filePath !== this.appPackageJsonPath) - .forEach(filePath => { - const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8')); - let hasUpdates = false; - - // Helper function to update dependencies of a specific type - const updateDependencySection = (section) => { - if (!packageJson[section]) return; - - Object.entries(packageJson[section]).forEach(([dep, version]) => { - // Skip workspace dependencies - if (version.startsWith('workspace:')) return; - - // If the dependency exists in the main app, sync the version - if (appDependencies[dep]) { - const appVersion = appDependencies[dep]; - if (version !== appVersion) { - if (!dryRun) { - packageJson[section][dep] = appVersion; - } - updates.push({ - package: packageJson.name, - dependency: dep, - from: version, - to: appVersion, - type: section - }); - hasUpdates = true; - } - } - }); - }; - - // Update all dependency sections - updateDependencySection('dependencies'); - updateDependencySection('devDependencies'); - updateDependencySection('peerDependencies'); - - // Write updated package.json if there were changes - if (hasUpdates && !dryRun) { - fs.writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n'); - } - }); - - // Display updates - if (updates.length === 0) { - console.log(chalk.green('No updates needed - all versions match the main app')); - } else { - updates.forEach(({ package: pkgName, dependency, from, to, type }) => { - console.log( - chalk.green( - `${dryRun ? '[DRY RUN] Would update' : 'Updated'} ${dependency} in ${pkgName} (${type})\n` + - ` ${chalk.red(from)} → ${chalk.green(to)}` - ) - ); - }); - } - - return updates; - } - - async run({ - update = false, - dryRun = false, - format = 'text', - checkVersions = false, - checkMissing = false, - } = {}) { - if (checkMissing) { - this.findPackageJsonFiles(); - this.checkMissingDependencies(); - return; - } - - if (checkVersions) { - this.findPackageJsonFiles(); - this.analyzeDependencies(); - this.displayVersionDifferences(); - return; - } - - if (update) { - this.findPackageJsonFiles(); - this.analyzeDependencies(); - await this.updateDependencies(dryRun); - return; - } - - // Default behavior - show full analysis - this.findPackageJsonFiles(); - this.analyzeDependencies(); - this.displayResults(format); - } -} - -// CLI implementation -const run = async () => { - // eslint-disable-next-line no-undef - const argv = yargs(hideBin(process.argv)) - .usage('Usage: $0 [options]') - .option('app', { - alias: 'a', - describe: 'Path to main app package.json', - type: 'string', - default: './package.json', - }) - .option('packages', { - alias: 'p', - describe: 'Path to packages directory', - type: 'string', - default: './packages', - }) - .option('update', { - alias: 'u', - describe: 'Update dependencies to highest compatible version', - type: 'boolean', - default: false, - }) - .option('dry-run', { - alias: 'd', - describe: 'Show what would be updated without making changes', - type: 'boolean', - default: false, - }) - .option('check-versions', { - alias: 'v', - describe: 'Check for version differences between packages', - type: 'boolean', - default: false, - }) - .option('check-missing', { - alias: 'm', - describe: 'Check for dependencies missing between app and packages', - type: 'boolean', - default: false, - }) - .option('format', { - alias: 'f', - describe: 'Output format (text or json)', - choices: ['text', 'json'], - default: 'text', - }) - .help() - .alias('help', 'h') - .example('$0 --check-versions', 'Show only version differences') - .example('$0 --check-missing', 'Show missing dependencies') - .example('$0 --update --dry-run', 'Show what would be updated').argv; - - // eslint-disable-next-line no-undef - const appPackageJson = path.resolve(process.cwd(), argv.app); - // eslint-disable-next-line no-undef - const packagesDir = path.resolve(process.cwd(), argv.packages); - - // Validate paths - if (!fs.existsSync(appPackageJson)) { - console.error(chalk.red(`Error: Main package.json not found at ${appPackageJson}`)); - // eslint-disable-next-line no-undef - process.exit(1); - } - - if (!fs.existsSync(packagesDir)) { - console.error(chalk.red(`Error: Packages directory not found at ${packagesDir}`)); - // eslint-disable-next-line no-undef - process.exit(1); - } - - const checker = new DependencyChecker(appPackageJson, packagesDir); - await checker.run({ - update: argv.update, - dryRun: argv.dryRun, - format: argv.format, - checkVersions: argv.checkVersions, - checkMissing: argv.checkMissing, - }); -}; - -// Export both the class and run function -export { DependencyChecker, run as default }; - -// eslint-disable-next-line no-undef -if (process.argv[1] === import.meta.url.slice(7)) { - run(); -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5731adb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,159 @@ +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { DependencyChecker } from './lib'; +export {DependencyChecker} + +const cli = async () => { + const yargsInstance = yargs(hideBin(process.argv)) + .usage('Usage: $0 [options]') + .option('app', { + alias: 'a', + describe: 'Path to main app package.json', + type: 'string', + default: './package.json', + }) + .option('packages', { + alias: 'p', + describe: 'Comma-separated list of paths to package.json files or directories containing packages', + type: 'string', + default: './packages', + }) + .option('update', { + alias: 'u', + describe: 'Update dependencies to highest compatible version', + type: 'boolean', + default: false, + }) + .option('dry-run', { + alias: 'd', + describe: 'Show what would be updated without making changes', + type: 'boolean', + default: false, + }) + .option('check-versions', { + alias: 'v', + describe: 'Check for version differences between packages', + type: 'boolean', + default: false, + }) + .option('check-missing', { + alias: 'm', + describe: 'Check for dependencies missing between app and packages', + type: 'boolean', + default: false, + }) + .option('format', { + alias: 'f', + describe: 'Output format (text or json)', + choices: ['text', 'json'], + default: 'text', + type:'string' + }) + .example('$0 --check-versions', 'Show only version differences') + .example('$0 --check-missing', 'Show missing dependencies') + .example('$0 --update --dry-run', 'Show what would be updated') + .example('$0 --packages ./packages,./other-packages', 'Check multiple package directories') + .example('$0 --packages ./pkg1/package.json,./pkg2/package.json', 'Check specific package.json files') + .example('$0 --packages ./packages,./other/package.json', 'Mix of directories and files') + .epilogue('For more information, visit: https://github.com/warkanum/monorepo-dep-checker') + .wrap(Math.min(120, process.stdout.columns)) + .version() + .help() + .alias('help', 'h') + .alias('version', 'V'); + + // Parse arguments + const argv = await yargsInstance.parse(); + + // If help or version was requested, exit early + // yargs.argv internally tracks if help or version was requested + if (argv.help || argv.version) { + return; + } + + // Resolve paths + const appPackageJson = path.resolve(process.cwd(), argv.app); + const packagesInput = argv.packages; + + // Validate main app package.json + if (!fs.existsSync(appPackageJson)) { + console.error(chalk.red(`Error: Main package.json not found at ${appPackageJson}`)); + process.exit(1); + } + + // Validate all package paths + const paths = packagesInput.split(',').map(p => path.resolve(process.cwd(), p.trim())); + let hasErrors = false; + + // biome-ignore lint/complexity/noForEach: Ease of reading and preference + paths.forEach(inputPath => { + try { + if (!fs.existsSync(inputPath)) { + console.error(chalk.red(`Error: Path not found: ${inputPath}`)); + hasErrors = true; + } else { + const stats = fs.statSync(inputPath); + if (!stats.isDirectory() && !(stats.isFile() && inputPath.endsWith('package.json'))) { + console.error(chalk.red(`Error: Path must be a directory or package.json file: ${inputPath}`)); + hasErrors = true; + } + } + } catch (error:any) { + console.error(chalk.red(`Error accessing path ${inputPath}: ${error?.message}`)); + hasErrors = true; + } + }); + + if (hasErrors) { + process.exit(1); + } + + // Validate mutually exclusive options + const exclusiveOptions = ['update', 'check-versions', 'check-missing'] + .filter(opt => argv[opt]) + .length; + + if (exclusiveOptions > 1) { + console.error(chalk.red('Error: --update, --check-versions, and --check-missing are mutually exclusive')); + process.exit(1); + } + + // Validate dry-run is only used with update + if (argv.dryRun && !argv.update) { + console.error(chalk.yellow('Warning: --dry-run has no effect without --update')); + } + + const checker = new DependencyChecker(appPackageJson, packagesInput); + + try { + await checker.run({ + update: argv.update, + dryRun: argv.dryRun, + format: argv.format as any, + checkVersions: argv.checkVersions, + checkMissing: argv.checkMissing, + }); + } catch (error:any) { + console.error(chalk.red('Error during execution:')); + console.error(error?.message); + if (argv.format === 'json') { + console.log(JSON.stringify({ error: error?.message })); + } + process.exit(1); + } +}; + +// Export both the CLI function and run it if this is the main module +export { cli as default }; + +if (process.argv[1] === import.meta.url.slice(7)) { + cli().catch(error => { + console.error(chalk.red('Unexpected error:')); + console.error(error); + process.exit(1); + }); +} + diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..42664ec --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,786 @@ +import fs, { type Dirent } from "fs"; +import path from "path"; +import chalk from "chalk"; +import semver from "semver"; + +interface PackageJson { + name: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +interface RunOptions { + update?: boolean; + dryRun?: boolean; + format?: "text" | "json"; + checkVersions?: boolean; + checkMissing?: boolean; +} + +interface VersionInfo { + packages: Set; + usages: Set; +} + +interface DependencyInfo { + versions: Map; + usedAsNormal: boolean; + usedAsPeer: boolean; +} + +interface PackagePathInfo { + package: string; + path: string; +} + +interface DependencyComparison { + version1: string; + version2: string; + packages1: PackagePathInfo[]; + packages2: PackagePathInfo[]; + semverDiff: string; +} + +interface ConflictingDep { + name: string; + versions: { + version: string; + packages: string[]; + paths: PackagePathInfo[]; + usages: string[]; + }[]; + comparisons: DependencyComparison[]; +} + +interface DependencyUpdate { + package: string; + dependency: string; + from: string; + to: string; + type: string; +} + +interface MissingDepsInfo { + path: string; + missing: Array<{ name: string; version: string }>; + extraDeps: Array<{ name: string; version: string }>; +} + +type DependencyTypes = "dependencies" | "devDependencies" | "peerDependencies"; + +class DependencyChecker { + private readonly appPackageJsonPath: string; + private readonly packagesInput: string; + private packageJsonFiles: string[]; + private dependencyMap: Map; + private workspacePackages: Set; + + constructor(appPackageJsonPath: string, packagesInput: string) { + this.appPackageJsonPath = appPackageJsonPath; + this.packagesInput = packagesInput; + this.packageJsonFiles = []; + this.dependencyMap = new Map(); + this.workspacePackages = new Set(); + } + + private findWorkspacePackages(): void { + const paths = this.packagesInput.split(",").map((p) => p.trim()); + + paths.forEach((inputPath) => { + if (fs.statSync(inputPath).isDirectory()) { + const entries = fs.readdirSync(inputPath, { withFileTypes: true }); + entries.forEach((entry: Dirent) => { + if (entry.isDirectory()) { + const packageJsonPath = path.join( + inputPath, + entry.name, + "package.json" + ); + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, "utf8") + ) as PackageJson; + this.workspacePackages.add(packageJson.name); + } + } + }); + } else if (inputPath.endsWith("package.json")) { + const packageJson = JSON.parse( + fs.readFileSync(inputPath, "utf8") + ) as PackageJson; + this.workspacePackages.add(packageJson.name); + } + }); + } + + private findPackageJsonFiles(): void { + this.packageJsonFiles = [this.appPackageJsonPath]; + const paths = this.packagesInput.split(",").map((p) => p.trim()); + + paths.forEach((inputPath) => { + if (fs.statSync(inputPath).isDirectory()) { + const entries = fs.readdirSync(inputPath, { withFileTypes: true }); + entries.forEach((entry: Dirent) => { + if (entry.isDirectory()) { + const packageJsonPath = path.join( + inputPath, + entry.name, + "package.json" + ); + if (fs.existsSync(packageJsonPath)) { + this.packageJsonFiles.push(packageJsonPath); + } + } + }); + } else if (inputPath.endsWith("package.json")) { + this.packageJsonFiles.push(inputPath); + } + }); + } + + private isWorkspaceDep(version?: string): boolean { + return !!version && (version.startsWith("workspace:") || version === "*"); + } + + private isWorkspacePackage(packageName: string): boolean { + return this.workspacePackages.has(packageName); + } + + private shouldIncludeDependency( + depName: string, + version: string, + packageJson: PackageJson + ): boolean { + if (this.isWorkspaceDep(version)) return false; + if (this.isWorkspacePackage(depName)) return false; + + const allWorkspaceDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + + return !Object.entries(allWorkspaceDeps).some( + ([name, ver]) => name === depName && this.isWorkspaceDep(ver) + ); + } + + private getSemverVersion(version: string): string | null { + if (version.startsWith("workspace:")) { + const workspaceVersion = version.replace("workspace:", ""); + if (workspaceVersion === "*") return null; + if (semver.valid(workspaceVersion)) return workspaceVersion; + return null; + } + + if (version === "*" || version === "latest") return null; + + const cleanVersion = version.replace(/^[~^]/, ""); + + try { + if (semver.valid(cleanVersion)) return cleanVersion; + return null; + } catch { + return null; + } + } + + private checkMissingDependencies(): void { + console.log(chalk.bold("\nChecking for missing dependencies...\n")); + + this.findWorkspacePackages(); + + const appPackageJson = JSON.parse( + fs.readFileSync(this.appPackageJsonPath, "utf8") + ) as PackageJson; + const appDeps = { + ...appPackageJson.dependencies, + ...appPackageJson.devDependencies, + ...appPackageJson.peerDependencies, + }; + + const missingDeps = new Map(); + const uniqueMissingDeps = new Set(); + const uniqueExtraDeps = new Set(); + + this.packageJsonFiles + .filter((file) => file !== this.appPackageJsonPath) + .forEach((filePath) => { + const packageJson = JSON.parse( + fs.readFileSync(filePath, "utf8") + ) as PackageJson; + const packageName = packageJson.name; + + const packageDeps = { + ...packageJson.dependencies, + ...packageJson.peerDependencies, + }; + + const missing: Array<{ name: string; version: string }> = []; + const extraDeps: Array<{ name: string; version: string }> = []; + + Object.entries(packageDeps).forEach(([dep, version]) => { + if ( + (!appDeps[dep] && + this.shouldIncludeDependency(dep, version, packageJson)) || + (packageJson.peerDependencies?.[dep] && !appDeps[dep]) + ) { + missing.push({ name: dep, version }); + uniqueMissingDeps.add(dep); + } + }); + + Object.entries(appDeps).forEach(([dep, version]) => { + if ( + !packageDeps[dep] && + this.shouldIncludeDependency(dep, version, appPackageJson) + ) { + extraDeps.push({ name: dep, version }); + uniqueExtraDeps.add(dep); + } + }); + + if (missing.length > 0 || extraDeps.length > 0) { + missingDeps.set(packageName, { + path: path.relative(process.cwd(), filePath), + missing, + extraDeps, + }); + } + }); + + if (missingDeps.size === 0) { + console.log( + chalk.green("✓ All dependencies are properly synchronized\n") + ); + return; + } + + missingDeps.forEach( + ({ path: pkgPath, missing, extraDeps }, packageName) => { + if (missing.length > 0 || extraDeps.length > 0) { + console.log( + chalk.yellow(`\n${packageName} (${chalk.gray(pkgPath)}):`) + ); + + if (missing.length > 0) { + console.log(chalk.red(" Missing from main app:")); + missing.forEach(({ name, version }) => { + console.log(` - ${name}@${version}`); + }); + } + + if (extraDeps.length > 0) { + console.log(chalk.blue(" Not used by package but in main app:")); + extraDeps.forEach(({ name, version }) => { + console.log(` - ${name}@${version}`); + }); + } + } + } + ); + + if (uniqueMissingDeps.size > 0 || uniqueExtraDeps.size > 0) { + console.log(chalk.bold("\nSummary:")); + console.log(`Packages with dependency mismatches: ${missingDeps.size}`); + if (uniqueMissingDeps.size > 0) { + console.log( + chalk.red( + `Unique dependencies missing from main app: ${uniqueMissingDeps.size}` + ) + ); + console.log( + chalk.gray(" " + Array.from(uniqueMissingDeps).join(", ")) + ); + } + if (uniqueExtraDeps.size > 0) { + console.log( + chalk.blue( + `Unique unused dependencies from main app: ${uniqueExtraDeps.size}` + ) + ); + console.log(chalk.gray(" " + Array.from(uniqueExtraDeps).join(", "))); + } + } + } + + private compareDependencyVersions( + version1: string, + version2: string + ): string { + const v1 = this.getSemverVersion(version1); + const v2 = this.getSemverVersion(version2); + + if (!v1 || !v2) return "unknown"; + + try { + const diff = semver.diff(v1, v2); + return diff || "unknown"; + } catch { + return "unknown"; + } + } + + private getVersionLabel(version: string): string { + if (version.startsWith("workspace:")) { + return `${chalk.cyan("workspace:")}${version.slice(10)}`; + } + return version; + } + + private analyzeDependencies(): void { + this.findWorkspacePackages(); + + this.packageJsonFiles.forEach((filePath) => { + const packageJson = JSON.parse( + fs.readFileSync(filePath, "utf8") + ) as PackageJson; + const normalDeps = packageJson.dependencies || {}; + const peerDeps = packageJson.peerDependencies || {}; + const devDeps = packageJson.devDependencies || {}; + const packageName = packageJson.name; + const isMainApp = filePath === this.appPackageJsonPath; + + // Process all dependency types + [ + { deps: normalDeps, type: "normal" }, + { deps: peerDeps, type: "peer" }, + { deps: devDeps, type: "dev" }, + ].forEach(({ deps, type }) => { + Object.entries(deps).forEach(([dep, version]) => { + if (!this.dependencyMap.has(dep)) { + this.dependencyMap.set(dep, { + versions: new Map(), + usedAsNormal: false, + usedAsPeer: false, + }); + } + + const depInfo = this.dependencyMap.get(dep)!; + if (!depInfo.versions.has(version)) { + depInfo.versions.set(version, { + packages: new Set(), + usages: new Set(), + }); + } + + const versionInfo = depInfo.versions.get(version)!; + versionInfo.packages.add(packageName); + versionInfo.usages.add(`${type}${isMainApp ? " (main app)" : ""}`); + + if (type === "normal") depInfo.usedAsNormal = true; + if (type === "peer") depInfo.usedAsPeer = true; + + // Track workspace dependencies explicitly + if (this.isWorkspaceDep(version)) { + versionInfo.usages.add("workspace"); + } + }); + }); + }); + } + + private displayVersionDifferences(): void { + console.log(chalk.bold("\nVersion Differences Summary:\n")); + + let hasDifferences = false; + const conflictingDeps: ConflictingDep[] = []; + + this.dependencyMap.forEach((depInfo, dep) => { + if (depInfo.versions.size > 1) { + hasDifferences = true; + const versions = Array.from(depInfo.versions.entries()).map( + ([version, info]) => ({ + version, + packages: Array.from(info.packages), + paths: Array.from(info.packages).map((pkg) => { + const filePath = this.packageJsonFiles.find((file) => { + const json = JSON.parse( + fs.readFileSync(file, "utf8") + ) as PackageJson; + return json.name === pkg; + }); + return { + package: pkg, + path: path.relative(process.cwd(), filePath || ""), + }; + }), + usages: Array.from(info.usages), + }) + ); + + const comparisons: DependencyComparison[] = []; + for (let i = 0; i < versions.length; i++) { + for (let j = i + 1; j < versions.length; j++) { + const v1 = versions[i]; + const v2 = versions[j]; + comparisons.push({ + version1: v1.version, + version2: v2.version, + packages1: v1.paths, + packages2: v2.paths, + semverDiff: this.compareDependencyVersions( + v1.version, + v2.version + ), + }); + } + } + + conflictingDeps.push({ + name: dep, + versions, + comparisons, + }); + } + }); + + if (!hasDifferences) { + console.log( + chalk.green("✓ No version differences found across packages\n") + ); + return; + } + + conflictingDeps.forEach(({ name, comparisons }) => { + console.log(chalk.yellow(`\n${name}:`)); + + comparisons.forEach( + ({ version1, version2, packages1, packages2, semverDiff }) => { + console.log( + chalk.cyan(`\n Difference (${semverDiff || "unknown"}):`) + ); + console.log( + ` ${this.getVersionLabel(version1)} vs ${this.getVersionLabel(version2)}` + ); + + console.log( + "\n Packages using", + chalk.green(this.getVersionLabel(version1)), + ":" + ); + packages1.forEach(({ package: pkg, path: filePath }) => { + console.log(` - ${chalk.blue(pkg)} (${chalk.gray(filePath)})`); + }); + + console.log( + "\n Packages using", + chalk.green(this.getVersionLabel(version2)), + ":" + ); + packages2.forEach(({ package: pkg, path: filePath }) => { + console.log(` - ${chalk.blue(pkg)} (${chalk.gray(filePath)})`); + }); + + console.log("\n Recommended action:"); + if ( + version1.startsWith("workspace:") || + version2.startsWith("workspace:") + ) { + console.log( + chalk.blue(" ℹ️ Workspace dependency - No action needed") + ); + } else { + switch (semverDiff) { + case "major": + console.log( + chalk.red( + " ⚠️ Major version difference - Manual review recommended" + ) + ); + break; + case "minor": + console.log( + chalk.yellow( + " ℹ️ Minor version difference - Consider updating to latest" + ) + ); + break; + case "patch": + console.log( + chalk.green( + " ✓ Patch version difference - Safe to update to latest" + ) + ); + break; + default: + console.log( + chalk.gray( + " ℹ️ Version difference analysis not available" + ) + ); + } + } + } + ); + }); + + console.log(chalk.bold("\nSummary Statistics:")); + const totalComparisons = conflictingDeps.reduce( + (acc, dep) => acc + dep.comparisons.length, + 0 + ); + console.log(`Dependencies with conflicts: ${conflictingDeps.length}`); + console.log(`Total version comparisons: ${totalComparisons}`); + + const severityCount: Record = {}; + // biome-ignore lint/complexity/noForEach: + conflictingDeps.forEach((dep) => { + dep.comparisons.forEach(({ version1, version2, semverDiff }) => { + if ( + version1.startsWith("workspace:") || + version2.startsWith("workspace:") + ) { + severityCount.workspace = (severityCount.workspace || 0) + 1; + } else { + severityCount[semverDiff || "unknown"] = + (severityCount[semverDiff || "unknown"] || 0) + 1; + } + }); + }); + + console.log("\nSeverity breakdown:"); + if (severityCount.workspace) + console.log( + chalk.blue(` Workspace dependencies: ${severityCount.workspace}`) + ); + if (severityCount.major) + console.log(chalk.red(` Major differences: ${severityCount.major}`)); + if (severityCount.minor) + console.log(chalk.yellow(` Minor differences: ${severityCount.minor}`)); + if (severityCount.patch) + console.log(chalk.green(` Patch differences: ${severityCount.patch}`)); + if (severityCount.unknown) + console.log( + chalk.gray(` Unknown differences: ${severityCount.unknown}`) + ); + } + + private async updateDependencies( + dryRun = false + ): Promise { + console.log(chalk.bold("\nUpdating dependencies...\n")); + + const appPackageJson = JSON.parse( + fs.readFileSync(this.appPackageJsonPath, "utf8") + ) as PackageJson; + const appDependencies = { + ...appPackageJson.dependencies, + ...appPackageJson.devDependencies, + ...appPackageJson.peerDependencies, + }; + + const updates: DependencyUpdate[] = []; + + this.packageJsonFiles + .filter((filePath) => filePath !== this.appPackageJsonPath) + .forEach((filePath) => { + const packageJson = JSON.parse( + fs.readFileSync(filePath, "utf8") + ) as PackageJson; + let hasUpdates = false; + + const updateDependencySection = (section: DependencyTypes): void => { + if (!packageJson[section]) return; + + Object.entries(packageJson[section]!).forEach(([dep, version]) => { + if (version.startsWith("workspace:")) return; + + if (appDependencies[dep]) { + const appVersion = appDependencies[dep]; + if (version !== appVersion) { + if (!dryRun) { + packageJson[section]![dep] = appVersion; + } + updates.push({ + package: packageJson.name, + dependency: dep, + from: version, + to: appVersion, + type: section, + }); + hasUpdates = true; + } + } + }); + }; + + updateDependencySection("dependencies"); + updateDependencySection("devDependencies"); + updateDependencySection("peerDependencies"); + + if (hasUpdates && !dryRun) { + fs.writeFileSync( + filePath, + JSON.stringify(packageJson, null, 2) + "\n" + ); + } + }); + + if (updates.length === 0) { + console.log( + chalk.green("No updates needed - all versions match the main app") + ); + } else { + updates.forEach(({ package: pkgName, dependency, from, to, type }) => { + console.log( + chalk.green( + `${dryRun ? "[DRY RUN] Would update" : "Updated"} ${dependency} in ${pkgName} (${type})\n` + + ` ${chalk.red(from)} → ${chalk.green(to)}` + ) + ); + }); + } + + return updates; + } + + private displayResults(format: "text" | "json" = "text"): void { + if (format === "json") { + const output = { + summary: { + conflicts: Array.from(this.dependencyMap.entries()) + .filter(([, depInfo]) => depInfo.versions.size > 1) + .map(([dep, depInfo]) => ({ + name: dep, + versions: Array.from(depInfo.versions.entries()).map( + ([version, info]) => ({ + version, + packages: Array.from(info.packages), + paths: Array.from(info.packages).map((pkg) => { + const filePath = this.packageJsonFiles.find((file) => { + const json = JSON.parse( + fs.readFileSync(file, "utf8") + ) as PackageJson; + return json.name === pkg; + }); + return { + package: pkg, + path: path.relative(process.cwd(), filePath || ""), + }; + }), + usages: Array.from(info.usages), + isWorkspace: this.isWorkspaceDep(version), + }) + ), + })), + }, + fullAnalysis: Object.fromEntries( + Array.from(this.dependencyMap.entries()).map(([dep, depInfo]) => [ + dep, + { + versions: Object.fromEntries( + Array.from(depInfo.versions.entries()).map( + ([version, info]) => [ + version, + { + packages: Array.from(info.packages), + usages: Array.from(info.usages), + isWorkspace: this.isWorkspaceDep(version), + }, + ] + ) + ), + usedAsNormal: depInfo.usedAsNormal, + usedAsPeer: depInfo.usedAsPeer, + }, + ]) + ), + }; + + console.log(JSON.stringify(output, null, 2)); + return; + } + + if (format === "text") { + this.displayVersionDifferences(); + return; + } + + const output = { + summary: { + conflicts: Array.from(this.dependencyMap.entries()) + .filter(([, depInfo]) => depInfo.versions.size > 1) + .map(([dep, depInfo]) => ({ + name: dep, + versions: Array.from(depInfo.versions.entries()).map( + ([version, info]) => ({ + version, + packages: Array.from(info.packages), + paths: Array.from(info.packages).map((pkg) => { + const filePath = this.packageJsonFiles.find((file) => { + const json = JSON.parse( + fs.readFileSync(file, "utf8") + ) as PackageJson; + return json.name === pkg; + }); + return { + package: pkg, + path: path.relative(process.cwd(), filePath || ""), + }; + }), + usages: Array.from(info.usages), + }) + ), + })), + }, + fullAnalysis: Object.fromEntries( + Array.from(this.dependencyMap.entries()).map(([dep, depInfo]) => [ + dep, + { + versions: Object.fromEntries( + Array.from(depInfo.versions.entries()).map(([version, info]) => [ + version, + { + packages: Array.from(info.packages), + usages: Array.from(info.usages), + }, + ]) + ), + usedAsNormal: depInfo.usedAsNormal, + usedAsPeer: depInfo.usedAsPeer, + }, + ]) + ), + }; + + console.log(JSON.stringify(output, null, 2)); + } + + public async run(options: RunOptions = {}): Promise { + const { + update = false, + dryRun = false, + format = "text", + checkVersions = false, + checkMissing = false, + } = options; + + if (checkMissing) { + this.findPackageJsonFiles(); + this.checkMissingDependencies(); + return; + } + + if (checkVersions) { + this.findPackageJsonFiles(); + this.analyzeDependencies(); + this.displayVersionDifferences(); + return; + } + + if (update) { + this.findPackageJsonFiles(); + this.analyzeDependencies(); + await this.updateDependencies(dryRun); + return; + } + + this.findPackageJsonFiles(); + this.analyzeDependencies(); + this.displayResults(format); + } +} + +export { DependencyChecker }; diff --git a/tests/app/package.json b/tests/app/package.json new file mode 100644 index 0000000..901aabf --- /dev/null +++ b/tests/app/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "lodash": "^4.17.21", + "express": "^4.18.2" + }, + "devDependencies": { + "typescript": "^5.0.0", + "jest": "^29.0.0" + } + } \ No newline at end of file diff --git a/tests/dependency-checker.test.ts b/tests/dependency-checker.test.ts new file mode 100644 index 0000000..1472f31 --- /dev/null +++ b/tests/dependency-checker.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { DependencyChecker } from '../src/lib'; + +// Mock console.log to prevent output during tests +const _log = console.log +console.log = vi.fn(); +console.error = vi.fn(); + +describe('DependencyChecker', () => { + const appPackageJsonPath = path.resolve(__dirname, './app/package.json'); + const packagesPath = path.resolve(__dirname, './packages'); + let checker: DependencyChecker; + + beforeEach(() => { + checker = new DependencyChecker(appPackageJsonPath, packagesPath); + // Clear console mocks before each test + console.log = vi.fn(); + console.error = vi.fn(); + }); + + test('should correctly identify version differences', async () => { + await checker.run({ checkVersions: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Version Differences Summary') + ); + + // Verify major version difference detection + const calls = (console.log as jest.Mock).mock.calls.map(call => call[0]).join('\n'); + expect(calls).toContain('Major version difference'); + }); + + test('should detect missing dependencies', async () => { + await checker.run({ checkMissing: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Checking for missing dependencies') + ); + }); + + test('should handle update with dry run', async () => { + const originalContent = fs.readFileSync( + path.join(packagesPath, 'pkg1/package.json'), + 'utf8' + ); + + await checker.run({ update: true, dryRun: true }); + + const newContent = fs.readFileSync( + path.join(packagesPath, 'pkg1/package.json'), + 'utf8' + ); + + expect(originalContent).toBe(newContent); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[DRY RUN]') + ); + }); + + test('should output JSON format correctly', async () => { + const consoleSpy = vi.spyOn(console, 'log'); + + await checker.run({ format: 'json' }); + + const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0]; + + expect(() => JSON.parse(lastCall)).not.toThrow(); + const output = JSON.parse(lastCall); + expect(output).toHaveProperty('summary'); + expect(output).toHaveProperty('fullAnalysis'); + }); + + test('should handle multiple package paths', async () => { + const multiPathChecker = new DependencyChecker( + appPackageJsonPath, + `${packagesPath},${path.join(packagesPath, 'pkg1/package.json')}` + ); + + await multiPathChecker.run({ checkVersions: true }); + + // Verify that dependencies from both paths are processed + const calls = (console.log as jest.Mock).mock.calls.map(call => call[0]).join('\n'); + expect(calls).toContain('Version Differences Summary'); + expect(calls).toContain('pkg1'); + }); + + test('should identify workspace dependencies', async () => { + // First create test files with workspace deps + const pkg3Path = path.join(packagesPath, 'pkg3/package.json'); + const pkg3Content = { + name: "pkg3", + version: "1.0.0", + dependencies: { + "workspace-pkg": "workspace:*", + "react": "^18.2.0" + } + }; + + // Ensure directory exists + fs.mkdirSync(path.dirname(pkg3Path), { recursive: true }); + fs.writeFileSync(pkg3Path, JSON.stringify(pkg3Content, null, 2)); + + // Run with default options to get full analysis + await checker.run({ format: 'json' }); + + // Get the last console.log call + const calls = (console.log as jest.Mock).mock.calls; + expect(calls.length).toBeGreaterThan(0); + + const lastCall = calls[calls.length - 1][0]; + expect(() => JSON.parse(lastCall)).not.toThrow(); + + const output = JSON.parse(lastCall); + expect(output.fullAnalysis).toHaveProperty('workspace-pkg'); + + // Clean up + fs.unlinkSync(pkg3Path); + }); + + + test('should handle errors gracefully', async () => { + const invalidChecker = new DependencyChecker( + 'invalid/path/package.json', + packagesPath + ); + + await expect(invalidChecker.run()).rejects.toThrow(); + }); + + test('should handle empty package directories', async () => { + // Create a temporary empty directory + const emptyDir = path.join(__dirname, 'empty'); + fs.mkdirSync(emptyDir, { recursive: true }); + + const emptyChecker = new DependencyChecker(appPackageJsonPath, emptyDir); + await emptyChecker.run({ checkVersions: true }); + + // Clean up + fs.rmdirSync(emptyDir); + + // Verify appropriate handling of empty directory + const calls = (console.log as jest.Mock).mock.calls.map(call => call[0]).join('\n'); + expect(calls).toContain('No version differences found'); + }); + + test('should detect version conflicts correctly', async () => { + await checker.run({ checkVersions: true }); + + const calls = (console.log as jest.Mock).mock.calls.map(call => call[0]).join('\n'); + expect(calls).toMatch(/Difference \((?:major|minor|patch)\)/); + }); + + +}); \ No newline at end of file diff --git a/tests/packages/pkg1/package.json b/tests/packages/pkg1/package.json new file mode 100644 index 0000000..dae9ab6 --- /dev/null +++ b/tests/packages/pkg1/package.json @@ -0,0 +1,11 @@ +{ + "name": "pkg1", + "version": "1.0.0", + "dependencies": { + "react": "^17.0.2", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "typescript": "^4.9.0" + } +} \ No newline at end of file diff --git a/tests/packages/pkg2/package.json b/tests/packages/pkg2/package.json new file mode 100644 index 0000000..7d18176 --- /dev/null +++ b/tests/packages/pkg2/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg2", + "version": "1.0.0", + "dependencies": { + "express": "^4.17.1", + "moment": "^2.29.4" + } + } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..751fc70 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["*.d.ts", "./src"], + "exclude": ["public", "dist", "./dist", "node_modules", "vite.config.js"], + "compilerOptions": { + "types": ["node"], + "target": "ES6", + "lib": ["DOM", "ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "jsx": "react-jsx", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/vite.config.js b/vite.config.js index 206c266..d558a13 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,10 +4,12 @@ import { resolve } from 'path'; export default defineConfig({ build: { lib: { - entry: resolve(__dirname, 'src/index.js'), + // eslint-disable-next-line no-undef + entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' }, + ssr:true, rollupOptions: { external: [ 'fs', @@ -17,10 +19,12 @@ export default defineConfig({ 'semver', 'yargs', 'yargs/helpers' - ] + ], }, target: 'node14', + outDir: 'dist', + emptyOutDir: true, sourcemap: true }