From ab8ec5a69c3d351db16f5b10d825c636dcd83057 Mon Sep 17 00:00:00 2001 From: Hein Date: Thu, 24 Sep 2026 09:54:44 +0200 Subject: [PATCH] feat(cli): add CI gating flags for dependency checks * Introduced --fail-on-diff and --fail-on-missing options * Updated run() to return a result summary * Enhanced README and tests to cover new functionality --- CHANGELOG.md | 6 + README.md | 47 +++++++- package.json | 6 +- pnpm-workspace.yaml | 5 + src/index.ts | 40 +++++- src/lib.ts | 201 ++++++++++++++++++++----------- tests/dependency-checker.test.ts | 71 ++++++++++- tsconfig.json | 2 +- 8 files changed, 303 insertions(+), 75 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3424bed..87ff8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @warkypublic/monorepo-dep-checker +## 1.0.6 + +### Patch Changes + +- Added --fail-on-diff and --fail-on-missing CI gating flags, with run() now returning a result summary instead of void. + ## 1.0.5 ### Patch Changes diff --git a/README.md b/README.md index 6f2fb7f..5f204ee 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A powerful CLI tool for managing and checking dependencies across packages in a - 🔄 Update dependencies to the highest compatible version - 🏗️ Full workspace package support - 📊 Clear, actionable summaries +- 🚦 CI gating via exit codes (`--fail-on-diff`, `--fail-on-missing`) - 🚀 Fast, zero-config setup ## Installation @@ -98,6 +99,32 @@ Get results in JSON format for further processing: dep-check --check-versions --format json ``` +### 6. CI Gating + +Exit with code 1 when the check finds a problem, so a pipeline fails: + +```bash +# Fail if any dependency resolves to more than one version +dep-check --check-versions --fail-on-diff + +# Fail if any package depends on something the main app does not declare +dep-check --check-missing --fail-on-missing + +# Both gates in one run +dep-check --fail-on-diff --fail-on-missing +``` + +| Exit code | Meaning | +| --- | --- | +| `0` | No gate was tripped | +| `1` | A gate was tripped, or the run errored | + +Notes: + +- A gate runs whatever analysis it needs, even outside its own mode — `--check-missing --fail-on-diff` still detects version conflicts. +- Failure messages go to **stderr**, so `--format json` keeps stdout parseable. +- Gates cannot be combined with `--update`: `--update` rewrites the tree mid-run, which would leave the exit code describing a state that no longer exists. + ## Command Line Options ```bash @@ -108,9 +135,12 @@ Options: --dry-run, -d Show what would be updated without making changes --check-versions Check for version differences between packages --check-missing Check for dependencies missing between app and packages - --format Output format (text or json) [default: "text"] - --help Show help - --version Show version number + --format, -f Output format (text or json) [default: "text"] + --strict, -s Only update range deps (~, ^, >=) when they are incompatible + --fail-on-diff Exit 1 if any dependency resolves to more than one version + --fail-on-missing Exit 1 if a package depends on something the main app does not + --help, -h Show help + --version, -V Show version number ``` ## Features @@ -142,6 +172,17 @@ Options: 2. Use `--dry-run` before applying updates 3. Review major version differences manually 4. Keep workspace dependencies consistent across packages +5. Gate CI with `--check-versions --fail-on-diff` so drift cannot merge + +## Development + +```bash +pnpm install +pnpm typecheck # tsc --noEmit +pnpm lint # eslint ./src +pnpm test # vitest +pnpm build # vite build -> dist/ +``` ## Contributing diff --git a/package.json b/package.json index 9574b77..ea27251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@warkypublic/monorepo-dep-checker", - "version": "1.0.5", + "version": "1.0.6", "description": "A CLI tool to check and manage dependencies across packages in a monorepo", "type": "module", "main": "./dist/index.js", @@ -17,8 +17,9 @@ ], "scripts": { "build": "vite build", - "prepublishOnly": "npm run build", + "prepublishOnly": "npm run typecheck && npm run build", "test": "vitest run --silent=false", + "typecheck": "tsc --noEmit", "lint": "eslint ./src" }, "keywords": [ @@ -50,6 +51,7 @@ "eslint": "^9.26.0", "globals": "^16.0.0", "prettier-eslint": "^16.4.1", + "typescript": "^5.9.3", "typescript-eslint": "^8.32.0", "typesync": "^0.14.3", "vite": "^6.3.5", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..06ac931 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +onlyBuiltDependencies: + - esbuild diff --git a/src/index.ts b/src/index.ts index 581577c..0667e44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,16 @@ const cli = async () => { default: 'text', type:'string' }) + .option('fail-on-diff', { + describe: 'Exit with code 1 if any dependency resolves to more than one version (CI gating)', + type: 'boolean', + default: false, + }) + .option('fail-on-missing', { + describe: 'Exit with code 1 if any package depends on something the main app does not (CI gating)', + type: 'boolean', + default: false, + }) .option('strict', { alias: 's', describe: 'Strict mode: only update packages with version ranges (~, ^, >=) if they are incompatible', @@ -66,6 +76,8 @@ const cli = async () => { .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') + .example('$0 --check-versions --fail-on-diff', 'Fail CI on version conflicts') + .example('$0 --check-missing --fail-on-missing', 'Fail CI on unsynchronised dependencies') .epilogue('For more information, visit: https://github.com/warkanum/monorepo-dep-checker') .wrap(Math.min(120, process.stdout.columns)) .version() @@ -134,17 +146,43 @@ const cli = async () => { console.error(chalk.yellow('Warning: --dry-run has no effect without --update')); } + // The gates report on the tree as found; --update rewrites it mid-run, which + // would leave the exit code describing a state that no longer exists. + if (argv.update && (argv.failOnDiff || argv.failOnMissing)) { + console.error( + chalk.red('Error: --fail-on-diff and --fail-on-missing cannot be combined with --update') + ); + process.exit(1); + } + const checker = new DependencyChecker(appPackageJson, packagesInput); try { - await checker.run({ + const result = await checker.run({ update: argv.update, dryRun: argv.dryRun, format: argv.format as any, checkVersions: argv.checkVersions, checkMissing: argv.checkMissing, strict: argv.strict, + failOnDiff: argv.failOnDiff, + failOnMissing: argv.failOnMissing, }); + + if (result.failed) { + // stderr, so --format json keeps stdout parseable. + if (argv.failOnDiff && result.conflicts > 0) { + console.error( + chalk.red(`\nFailed: ${result.conflicts} dependenc${result.conflicts === 1 ? 'y' : 'ies'} with conflicting versions`) + ); + } + if (argv.failOnMissing && result.missing > 0) { + console.error( + chalk.red(`\nFailed: ${result.missing} dependenc${result.missing === 1 ? 'y' : 'ies'} missing from the main app`) + ); + } + process.exit(1); + } } catch (error:any) { console.error(chalk.red('Error during execution:')); console.error(error?.message); diff --git a/src/lib.ts b/src/lib.ts index 9c0d198..e1cdc7b 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -17,6 +17,21 @@ interface RunOptions { checkVersions?: boolean; checkMissing?: boolean; strict?: boolean; + failOnDiff?: boolean; + failOnMissing?: boolean; +} + +interface RunResult { + /** Dependencies resolved to more than one version across packages. */ + conflicts: number; + /** Unique dependencies used by a package but absent from the main app. */ + missing: number; + /** Unique dependencies in the main app that no package uses. */ + extra: number; + /** Dependency versions rewritten (or that would be, under --dry-run). */ + updates: number; + /** True when a --fail-on-* gate was tripped. */ + failed: boolean; } interface VersionInfo { @@ -77,6 +92,10 @@ class DependencyChecker { private dependencyMap: Map; private workspacePackages: Set; private strictMode: boolean; + private packageJsonCache: Map; + private packagePathByName: Map | null; + private workspacesScanned: boolean; + private analyzed: boolean; constructor(appPackageJsonPath: string, packagesInput: string) { this.appPackageJsonPath = appPackageJsonPath; @@ -85,9 +104,50 @@ class DependencyChecker { this.dependencyMap = new Map(); this.workspacePackages = new Set(); this.strictMode = false; + this.packageJsonCache = new Map(); + this.packagePathByName = null; + this.workspacesScanned = false; + this.analyzed = false; + } + + /** + * Parse a package.json once and reuse it. Every lookup in this class goes + * through here, so a package.json is read from disk at most once per run. + */ + private readPackageJson(filePath: string): PackageJson { + const cached = this.packageJsonCache.get(filePath); + if (cached) return cached; + + const packageJson = JSON.parse( + fs.readFileSync(filePath, "utf8") + ) as PackageJson; + this.packageJsonCache.set(filePath, packageJson); + return packageJson; + } + + /** + * Resolve a package name to the package.json that declares it. Built once + * from the cache instead of rescanning every file per lookup. First + * declaration wins, matching the previous find()-based behaviour. + */ + private getPackagePath(packageName: string): string { + if (!this.packagePathByName) { + this.packagePathByName = new Map(); + this.packageJsonFiles.forEach((filePath) => { + const { name } = this.readPackageJson(filePath); + if (name && !this.packagePathByName!.has(name)) { + this.packagePathByName!.set(name, filePath); + } + }); + } + + return this.packagePathByName.get(packageName) || ""; } private findWorkspacePackages(): void { + if (this.workspacesScanned) return; + this.workspacesScanned = true; + const paths = this.packagesInput.split(",").map((p) => p.trim()); paths.forEach((inputPath) => { @@ -101,24 +161,21 @@ class DependencyChecker { "package.json" ); if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse( - fs.readFileSync(packageJsonPath, "utf8") - ) as PackageJson; - this.workspacePackages.add(packageJson.name); + this.workspacePackages.add( + this.readPackageJson(packageJsonPath).name + ); } } }); } else if (inputPath.endsWith("package.json")) { - const packageJson = JSON.parse( - fs.readFileSync(inputPath, "utf8") - ) as PackageJson; - this.workspacePackages.add(packageJson.name); + this.workspacePackages.add(this.readPackageJson(inputPath).name); } }); } private findPackageJsonFiles(): void { this.packageJsonFiles = [this.appPackageJsonPath]; + this.packagePathByName = null; const paths = this.packagesInput.split(",").map((p) => p.trim()); paths.forEach((inputPath) => { @@ -206,14 +263,12 @@ class DependencyChecker { } } - private checkMissingDependencies(): void { + private checkMissingDependencies(): { missing: number; extra: number } { console.log(chalk.bold("\nChecking for missing dependencies...\n")); this.findWorkspacePackages(); - const appPackageJson = JSON.parse( - fs.readFileSync(this.appPackageJsonPath, "utf8") - ) as PackageJson; + const appPackageJson = this.readPackageJson(this.appPackageJsonPath); const appDeps = { ...appPackageJson.dependencies, ...appPackageJson.devDependencies, @@ -227,9 +282,7 @@ class DependencyChecker { this.packageJsonFiles .filter((file) => file !== this.appPackageJsonPath) .forEach((filePath) => { - const packageJson = JSON.parse( - fs.readFileSync(filePath, "utf8") - ) as PackageJson; + const packageJson = this.readPackageJson(filePath); const packageName = packageJson.name; const packageDeps = { @@ -273,7 +326,7 @@ class DependencyChecker { console.log( chalk.green("✓ All dependencies are properly synchronized\n") ); - return; + return { missing: 0, extra: 0 }; } missingDeps.forEach( @@ -322,6 +375,17 @@ class DependencyChecker { console.log(chalk.gray(" " + Array.from(uniqueExtraDeps).join(", "))); } } + + return { missing: uniqueMissingDeps.size, extra: uniqueExtraDeps.size }; + } + + /** Dependencies that resolved to more than one version across packages. */ + private countConflicts(): number { + let conflicts = 0; + this.dependencyMap.forEach((depInfo) => { + if (depInfo.versions.size > 1) conflicts++; + }); + return conflicts; } private compareDependencyVersions( @@ -349,12 +413,13 @@ class DependencyChecker { } private analyzeDependencies(): void { + if (this.analyzed) return; + this.analyzed = true; + this.findWorkspacePackages(); this.packageJsonFiles.forEach((filePath) => { - const packageJson = JSON.parse( - fs.readFileSync(filePath, "utf8") - ) as PackageJson; + const packageJson = this.readPackageJson(filePath); const normalDeps = packageJson.dependencies || {}; const peerDeps = packageJson.peerDependencies || {}; const devDeps = packageJson.devDependencies || {}; @@ -413,18 +478,10 @@ class DependencyChecker { ([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 || ""), - }; - }), + paths: Array.from(info.packages).map((pkg) => ({ + package: pkg, + path: path.relative(process.cwd(), this.getPackagePath(pkg)), + })), usages: Array.from(info.usages), }) ); @@ -581,9 +638,7 @@ class DependencyChecker { ): Promise { console.log(chalk.bold("\nUpdating dependencies...\n")); - const appPackageJson = JSON.parse( - fs.readFileSync(this.appPackageJsonPath, "utf8") - ) as PackageJson; + const appPackageJson = this.readPackageJson(this.appPackageJsonPath); const appDependencies = { ...appPackageJson.dependencies, ...appPackageJson.devDependencies, @@ -595,9 +650,7 @@ class DependencyChecker { this.packageJsonFiles .filter((filePath) => filePath !== this.appPackageJsonPath) .forEach((filePath) => { - const packageJson = JSON.parse( - fs.readFileSync(filePath, "utf8") - ) as PackageJson; + const packageJson = this.readPackageJson(filePath); let hasUpdates = false; const updateDependencySection = (section: DependencyTypes): void => { @@ -704,18 +757,10 @@ class DependencyChecker { ([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 || ""), - }; - }), + paths: Array.from(info.packages).map((pkg) => ({ + package: pkg, + path: path.relative(process.cwd(), this.getPackagePath(pkg)), + })), usages: Array.from(info.usages), isWorkspace: this.isWorkspaceDep(version), }) @@ -752,7 +797,7 @@ class DependencyChecker { this.displayVersionDifferences(); } - public async run(options: RunOptions = {}): Promise { + public async run(options: RunOptions = {}): Promise { const { update = false, dryRun = false, @@ -760,34 +805,56 @@ class DependencyChecker { checkVersions = false, checkMissing = false, strict = false, + failOnDiff = false, + failOnMissing = false, } = options; this.strictMode = strict; + this.findPackageJsonFiles(); + + const result: RunResult = { + conflicts: 0, + missing: 0, + extra: 0, + updates: 0, + failed: false, + }; if (checkMissing) { - this.findPackageJsonFiles(); - this.checkMissingDependencies(); - return; - } - - if (checkVersions) { - this.findPackageJsonFiles(); + const counts = this.checkMissingDependencies(); + result.missing = counts.missing; + result.extra = counts.extra; + } else if (checkVersions) { this.analyzeDependencies(); this.displayVersionDifferences(); - return; - } - - if (update) { - this.findPackageJsonFiles(); + } else if (update) { this.analyzeDependencies(); - await this.updateDependencies(dryRun); - return; + result.updates = (await this.updateDependencies(dryRun)).length; + } else { + this.analyzeDependencies(); + this.displayResults(format); } - this.findPackageJsonFiles(); - this.analyzeDependencies(); - this.displayResults(format); + // A --fail-on-* gate may need analysis the selected mode did not run. + if (failOnMissing && !checkMissing) { + const counts = this.checkMissingDependencies(); + result.missing = counts.missing; + result.extra = counts.extra; + } + + if (failOnDiff && !this.analyzed) { + this.analyzeDependencies(); + this.displayVersionDifferences(); + } + + result.conflicts = this.countConflicts(); + result.failed = + (failOnDiff && result.conflicts > 0) || + (failOnMissing && result.missing > 0); + + return result; } } export { DependencyChecker }; +export type { RunOptions, RunResult }; diff --git a/tests/dependency-checker.test.ts b/tests/dependency-checker.test.ts index 1472f31..c9a6c7a 100644 --- a/tests/dependency-checker.test.ts +++ b/tests/dependency-checker.test.ts @@ -152,5 +152,74 @@ describe('DependencyChecker', () => { expect(calls).toMatch(/Difference \((?:major|minor|patch)\)/); }); + test('should report conflict and missing counts from run()', async () => { + const versionResult = await checker.run({ checkVersions: true }); + // react (^18.2.0 vs ^17.0.2) and typescript (^5.0.0 vs ^4.9.0) both differ. + expect(versionResult.conflicts).toBeGreaterThan(0); + expect(versionResult.failed).toBe(false); -}); \ No newline at end of file + const missingChecker = new DependencyChecker(appPackageJsonPath, packagesPath); + const missingResult = await missingChecker.run({ checkMissing: true }); + // pkg2 depends on moment, which the app does not declare. + expect(missingResult.missing).toBeGreaterThan(0); + expect(missingResult.failed).toBe(false); + }); + + test('should flag failure when --fail-on-diff finds conflicts', async () => { + const result = await checker.run({ checkVersions: true, failOnDiff: true }); + + expect(result.conflicts).toBeGreaterThan(0); + expect(result.failed).toBe(true); + }); + + test('should flag failure when --fail-on-missing finds gaps', async () => { + const result = await checker.run({ checkMissing: true, failOnMissing: true }); + + expect(result.missing).toBeGreaterThan(0); + expect(result.failed).toBe(true); + }); + + test('should run the analysis a gate needs even outside its own mode', async () => { + // checkMissing mode does not analyse versions; the gate must still work. + const result = await checker.run({ checkMissing: true, failOnDiff: true }); + + expect(result.conflicts).toBeGreaterThan(0); + expect(result.failed).toBe(true); + }); + + test('should not fail a clean tree', async () => { + const cleanDir = path.join(__dirname, 'clean'); + fs.mkdirSync(cleanDir, { recursive: true }); + + const cleanChecker = new DependencyChecker(appPackageJsonPath, cleanDir); + const result = await cleanChecker.run({ + checkVersions: true, + failOnDiff: true, + failOnMissing: true, + }); + + fs.rmSync(cleanDir, { recursive: true, force: true }); + + expect(result.conflicts).toBe(0); + expect(result.missing).toBe(0); + expect(result.failed).toBe(false); + }); + + test('should read each package.json at most once per run', async () => { + const readSpy = vi.spyOn(fs, 'readFileSync'); + + await checker.run({ format: 'json', failOnMissing: true }); + + const reads = readSpy.mock.calls + .map((call) => String(call[0])) + .filter((file) => file.endsWith('package.json')); + + const perFile = new Map(); + reads.forEach((file) => perFile.set(file, (perFile.get(file) || 0) + 1)); + + readSpy.mockRestore(); + + expect(perFile.size).toBeGreaterThan(0); + Array.from(perFile.values()).forEach((count) => expect(count).toBe(1)); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 751fc70..a85d0ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "target": "ES6", "lib": ["DOM", "ESNext"], "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "jsx": "react-jsx", "resolveJsonModule": true, "esModuleInterop": true,