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
This commit is contained in:
warkanum committed 2026-09-24 09:54:44 +02:00
1 parent c593038155
commit ab8ec5a69c
8 files changed
+300 -72

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# @warkypublic/monorepo-dep-checker # @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 ## 1.0.5
### Patch Changes ### Patch Changes
+44 -3
View File
@@ -9,6 +9,7 @@ A powerful CLI tool for managing and checking dependencies across packages in a
- 🔄 Update dependencies to the highest compatible version - 🔄 Update dependencies to the highest compatible version
- 🏗️ Full workspace package support - 🏗️ Full workspace package support
- 📊 Clear, actionable summaries - 📊 Clear, actionable summaries
- 🚦 CI gating via exit codes (`--fail-on-diff`, `--fail-on-missing`)
- 🚀 Fast, zero-config setup - 🚀 Fast, zero-config setup
## Installation ## Installation
@@ -98,6 +99,32 @@ Get results in JSON format for further processing:
dep-check --check-versions --format json 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 ## Command Line Options
```bash ```bash
@@ -108,9 +135,12 @@ Options:
--dry-run, -d Show what would be updated without making changes --dry-run, -d Show what would be updated without making changes
--check-versions Check for version differences between packages --check-versions Check for version differences between packages
--check-missing Check for dependencies missing between app and packages --check-missing Check for dependencies missing between app and packages
--format Output format (text or json) [default: "text"] --format, -f Output format (text or json) [default: "text"]
--help Show help --strict, -s Only update range deps (~, ^, >=) when they are incompatible
--version Show version number --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 ## Features
@@ -142,6 +172,17 @@ Options:
2. Use `--dry-run` before applying updates 2. Use `--dry-run` before applying updates
3. Review major version differences manually 3. Review major version differences manually
4. Keep workspace dependencies consistent across packages 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 ## Contributing
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@warkypublic/monorepo-dep-checker", "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", "description": "A CLI tool to check and manage dependencies across packages in a monorepo",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -17,8 +17,9 @@
], ],
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run typecheck && npm run build",
"test": "vitest run --silent=false", "test": "vitest run --silent=false",
"typecheck": "tsc --noEmit",
"lint": "eslint ./src" "lint": "eslint ./src"
}, },
"keywords": [ "keywords": [
@@ -50,6 +51,7 @@
"eslint": "^9.26.0", "eslint": "^9.26.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"prettier-eslint": "^16.4.1", "prettier-eslint": "^16.4.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.32.0", "typescript-eslint": "^8.32.0",
"typesync": "^0.14.3", "typesync": "^0.14.3",
"vite": "^6.3.5", "vite": "^6.3.5",
+5
View File
@@ -0,0 +1,5 @@
packages:
- .
onlyBuiltDependencies:
- esbuild
+39 -1
View File
@@ -53,6 +53,16 @@ const cli = async () => {
default: 'text', default: 'text',
type:'string' 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', { .option('strict', {
alias: 's', alias: 's',
describe: 'Strict mode: only update packages with version ranges (~, ^, >=) if they are incompatible', 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 ./packages,./other-packages', 'Check multiple package directories')
.example('$0 --packages ./pkg1/package.json,./pkg2/package.json', 'Check specific package.json files') .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 --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') .epilogue('For more information, visit: https://github.com/warkanum/monorepo-dep-checker')
.wrap(Math.min(120, process.stdout.columns)) .wrap(Math.min(120, process.stdout.columns))
.version() .version()
@@ -134,17 +146,43 @@ const cli = async () => {
console.error(chalk.yellow('Warning: --dry-run has no effect without --update')); 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); const checker = new DependencyChecker(appPackageJson, packagesInput);
try { try {
await checker.run({ const result = await checker.run({
update: argv.update, update: argv.update,
dryRun: argv.dryRun, dryRun: argv.dryRun,
format: argv.format as any, format: argv.format as any,
checkVersions: argv.checkVersions, checkVersions: argv.checkVersions,
checkMissing: argv.checkMissing, checkMissing: argv.checkMissing,
strict: argv.strict, 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) { } catch (error:any) {
console.error(chalk.red('Error during execution:')); console.error(chalk.red('Error during execution:'));
console.error(error?.message); console.error(error?.message);
+132 -65
View File
@@ -17,6 +17,21 @@ interface RunOptions {
checkVersions?: boolean; checkVersions?: boolean;
checkMissing?: boolean; checkMissing?: boolean;
strict?: 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 { interface VersionInfo {
@@ -77,6 +92,10 @@ class DependencyChecker {
private dependencyMap: Map<string, DependencyInfo>; private dependencyMap: Map<string, DependencyInfo>;
private workspacePackages: Set<string>; private workspacePackages: Set<string>;
private strictMode: boolean; private strictMode: boolean;
private packageJsonCache: Map<string, PackageJson>;
private packagePathByName: Map<string, string> | null;
private workspacesScanned: boolean;
private analyzed: boolean;
constructor(appPackageJsonPath: string, packagesInput: string) { constructor(appPackageJsonPath: string, packagesInput: string) {
this.appPackageJsonPath = appPackageJsonPath; this.appPackageJsonPath = appPackageJsonPath;
@@ -85,9 +104,50 @@ class DependencyChecker {
this.dependencyMap = new Map(); this.dependencyMap = new Map();
this.workspacePackages = new Set(); this.workspacePackages = new Set();
this.strictMode = false; 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 { private findWorkspacePackages(): void {
if (this.workspacesScanned) return;
this.workspacesScanned = true;
const paths = this.packagesInput.split(",").map((p) => p.trim()); const paths = this.packagesInput.split(",").map((p) => p.trim());
paths.forEach((inputPath) => { paths.forEach((inputPath) => {
@@ -101,24 +161,21 @@ class DependencyChecker {
"package.json" "package.json"
); );
if (fs.existsSync(packageJsonPath)) { if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse( this.workspacePackages.add(
fs.readFileSync(packageJsonPath, "utf8") this.readPackageJson(packageJsonPath).name
) as PackageJson; );
this.workspacePackages.add(packageJson.name);
} }
} }
}); });
} else if (inputPath.endsWith("package.json")) { } else if (inputPath.endsWith("package.json")) {
const packageJson = JSON.parse( this.workspacePackages.add(this.readPackageJson(inputPath).name);
fs.readFileSync(inputPath, "utf8")
) as PackageJson;
this.workspacePackages.add(packageJson.name);
} }
}); });
} }
private findPackageJsonFiles(): void { private findPackageJsonFiles(): void {
this.packageJsonFiles = [this.appPackageJsonPath]; this.packageJsonFiles = [this.appPackageJsonPath];
this.packagePathByName = null;
const paths = this.packagesInput.split(",").map((p) => p.trim()); const paths = this.packagesInput.split(",").map((p) => p.trim());
paths.forEach((inputPath) => { 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")); console.log(chalk.bold("\nChecking for missing dependencies...\n"));
this.findWorkspacePackages(); this.findWorkspacePackages();
const appPackageJson = JSON.parse( const appPackageJson = this.readPackageJson(this.appPackageJsonPath);
fs.readFileSync(this.appPackageJsonPath, "utf8")
) as PackageJson;
const appDeps = { const appDeps = {
...appPackageJson.dependencies, ...appPackageJson.dependencies,
...appPackageJson.devDependencies, ...appPackageJson.devDependencies,
@@ -227,9 +282,7 @@ class DependencyChecker {
this.packageJsonFiles this.packageJsonFiles
.filter((file) => file !== this.appPackageJsonPath) .filter((file) => file !== this.appPackageJsonPath)
.forEach((filePath) => { .forEach((filePath) => {
const packageJson = JSON.parse( const packageJson = this.readPackageJson(filePath);
fs.readFileSync(filePath, "utf8")
) as PackageJson;
const packageName = packageJson.name; const packageName = packageJson.name;
const packageDeps = { const packageDeps = {
@@ -273,7 +326,7 @@ class DependencyChecker {
console.log( console.log(
chalk.green("✓ All dependencies are properly synchronized\n") chalk.green("✓ All dependencies are properly synchronized\n")
); );
return; return { missing: 0, extra: 0 };
} }
missingDeps.forEach( missingDeps.forEach(
@@ -322,6 +375,17 @@ class DependencyChecker {
console.log(chalk.gray(" " + Array.from(uniqueExtraDeps).join(", "))); 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( private compareDependencyVersions(
@@ -349,12 +413,13 @@ class DependencyChecker {
} }
private analyzeDependencies(): void { private analyzeDependencies(): void {
if (this.analyzed) return;
this.analyzed = true;
this.findWorkspacePackages(); this.findWorkspacePackages();
this.packageJsonFiles.forEach((filePath) => { this.packageJsonFiles.forEach((filePath) => {
const packageJson = JSON.parse( const packageJson = this.readPackageJson(filePath);
fs.readFileSync(filePath, "utf8")
) as PackageJson;
const normalDeps = packageJson.dependencies || {}; const normalDeps = packageJson.dependencies || {};
const peerDeps = packageJson.peerDependencies || {}; const peerDeps = packageJson.peerDependencies || {};
const devDeps = packageJson.devDependencies || {}; const devDeps = packageJson.devDependencies || {};
@@ -413,18 +478,10 @@ class DependencyChecker {
([version, info]) => ({ ([version, info]) => ({
version, version,
packages: Array.from(info.packages), packages: Array.from(info.packages),
paths: Array.from(info.packages).map((pkg) => { 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, package: pkg,
path: path.relative(process.cwd(), filePath || ""), path: path.relative(process.cwd(), this.getPackagePath(pkg)),
}; })),
}),
usages: Array.from(info.usages), usages: Array.from(info.usages),
}) })
); );
@@ -581,9 +638,7 @@ class DependencyChecker {
): Promise<DependencyUpdate[]> { ): Promise<DependencyUpdate[]> {
console.log(chalk.bold("\nUpdating dependencies...\n")); console.log(chalk.bold("\nUpdating dependencies...\n"));
const appPackageJson = JSON.parse( const appPackageJson = this.readPackageJson(this.appPackageJsonPath);
fs.readFileSync(this.appPackageJsonPath, "utf8")
) as PackageJson;
const appDependencies = { const appDependencies = {
...appPackageJson.dependencies, ...appPackageJson.dependencies,
...appPackageJson.devDependencies, ...appPackageJson.devDependencies,
@@ -595,9 +650,7 @@ class DependencyChecker {
this.packageJsonFiles this.packageJsonFiles
.filter((filePath) => filePath !== this.appPackageJsonPath) .filter((filePath) => filePath !== this.appPackageJsonPath)
.forEach((filePath) => { .forEach((filePath) => {
const packageJson = JSON.parse( const packageJson = this.readPackageJson(filePath);
fs.readFileSync(filePath, "utf8")
) as PackageJson;
let hasUpdates = false; let hasUpdates = false;
const updateDependencySection = (section: DependencyTypes): void => { const updateDependencySection = (section: DependencyTypes): void => {
@@ -704,18 +757,10 @@ class DependencyChecker {
([version, info]) => ({ ([version, info]) => ({
version, version,
packages: Array.from(info.packages), packages: Array.from(info.packages),
paths: Array.from(info.packages).map((pkg) => { 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, package: pkg,
path: path.relative(process.cwd(), filePath || ""), path: path.relative(process.cwd(), this.getPackagePath(pkg)),
}; })),
}),
usages: Array.from(info.usages), usages: Array.from(info.usages),
isWorkspace: this.isWorkspaceDep(version), isWorkspace: this.isWorkspaceDep(version),
}) })
@@ -752,7 +797,7 @@ class DependencyChecker {
this.displayVersionDifferences(); this.displayVersionDifferences();
} }
public async run(options: RunOptions = {}): Promise<void> { public async run(options: RunOptions = {}): Promise<RunResult> {
const { const {
update = false, update = false,
dryRun = false, dryRun = false,
@@ -760,34 +805,56 @@ class DependencyChecker {
checkVersions = false, checkVersions = false,
checkMissing = false, checkMissing = false,
strict = false, strict = false,
failOnDiff = false,
failOnMissing = false,
} = options; } = options;
this.strictMode = strict; this.strictMode = strict;
this.findPackageJsonFiles();
const result: RunResult = {
conflicts: 0,
missing: 0,
extra: 0,
updates: 0,
failed: false,
};
if (checkMissing) { if (checkMissing) {
this.findPackageJsonFiles(); const counts = this.checkMissingDependencies();
this.checkMissingDependencies(); result.missing = counts.missing;
return; result.extra = counts.extra;
} } else if (checkVersions) {
if (checkVersions) {
this.findPackageJsonFiles();
this.analyzeDependencies(); this.analyzeDependencies();
this.displayVersionDifferences(); this.displayVersionDifferences();
return; } else if (update) {
}
if (update) {
this.findPackageJsonFiles();
this.analyzeDependencies(); this.analyzeDependencies();
await this.updateDependencies(dryRun); result.updates = (await this.updateDependencies(dryRun)).length;
return; } else {
}
this.findPackageJsonFiles();
this.analyzeDependencies(); this.analyzeDependencies();
this.displayResults(format); 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 { DependencyChecker };
export type { RunOptions, RunResult };
+69
View File
@@ -152,5 +152,74 @@ describe('DependencyChecker', () => {
expect(calls).toMatch(/Difference \((?:major|minor|patch)\)/); 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);
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<string, number>();
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));
});
}); });
+1 -1
View File
@@ -6,7 +6,7 @@
"target": "ES6", "target": "ES6",
"lib": ["DOM", "ESNext"], "lib": ["DOM", "ESNext"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,