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
+303 -75

No files matched your search

+6
View File
@@ -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
+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
- 🏗️ 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
+4 -2
View File
@@ -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",
+5
View File
@@ -0,0 +1,5 @@
packages:
- .
onlyBuiltDependencies:
- esbuild
+39 -1
View File
@@ -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);
+134 -67
View File
@@ -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<string, DependencyInfo>;
private workspacePackages: Set<string>;
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) {
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<DependencyUpdate[]> {
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<void> {
public async run(options: RunOptions = {}): Promise<RunResult> {
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 };
+70 -1
View File
@@ -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);
});
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",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"resolveJsonModule": true,
"esModuleInterop": true,