From 29b655f83f99ce2ed8f6ca4904580110bfe707e4 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 14:50:17 +0100 Subject: [PATCH 01/15] check-transitive-deps script --- .check-transitive-deps.json | 4 + .../bin/check-transitive-deps.js | 3 + packages/monorepo-tools/package.json | 3 +- .../src/check-transitive-deps.ts | 275 ++++++++++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 .check-transitive-deps.json create mode 100644 packages/monorepo-tools/bin/check-transitive-deps.js create mode 100644 packages/monorepo-tools/src/check-transitive-deps.ts diff --git a/.check-transitive-deps.json b/.check-transitive-deps.json new file mode 100644 index 00000000..8553a0bb --- /dev/null +++ b/.check-transitive-deps.json @@ -0,0 +1,4 @@ +{ + "deps": ["@mongodb-js/*", "mongodb", "mongodb-*"], + "transitiveDeps": ["@mongodb-js/*", "mongodb", "mongodb-*"] +} diff --git a/packages/monorepo-tools/bin/check-transitive-deps.js b/packages/monorepo-tools/bin/check-transitive-deps.js new file mode 100644 index 00000000..14481b08 --- /dev/null +++ b/packages/monorepo-tools/bin/check-transitive-deps.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +'use strict'; +require('../dist/check-transitive-deps.js'); diff --git a/packages/monorepo-tools/package.json b/packages/monorepo-tools/package.json index 76e21c35..3a020187 100644 --- a/packages/monorepo-tools/package.json +++ b/packages/monorepo-tools/package.json @@ -27,7 +27,8 @@ "depalign": "./bin/depalign.js", "monorepo-where": "./bin/where.js", "bump-monorepo-packages": "./bin/bump-packages.js", - "request-npm-token": "./bin/request-npm-token.js" + "request-npm-token": "./bin/request-npm-token.js", + "check-transitive-deps": "./bin/check-transitive-deps.js" }, "scripts": { "bootstrap": "npm run compile", diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts new file mode 100644 index 00000000..8a4e3eea --- /dev/null +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -0,0 +1,275 @@ +#! /usr/bin/env node +/* eslint-disable no-console */ + +import path from 'path'; +import { promises as fs } from 'fs'; +import chalk from 'chalk'; +import pacote from 'pacote'; +import { listAllPackages } from './utils/list-all-packages'; +import minimist from 'minimist'; +import type { ParsedArgs } from 'minimist'; + +const DEPENDENCY_GROUPS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +const USAGE = `Check transitive dependencies for version alignment. + +USAGE: check-transitive-deps.js [--deps ] [--transitive-deps ] [--config ] + +Options: + + --deps Comma-separated list of direct dependencies to track. + --transitive-deps Comma-separated list of transitive dependencies to check alignment for. + --config Path to config file. Default is .check-transitive-deps.json + +Config file format (.check-transitive-deps.json): + { + "deps": ["package-a", "@my-scope/*"], + "transitiveDeps": ["package-x", "package-y"] + } + +Glob patterns are supported: * matches any sequence of characters except /. +For example, @mongodb-js/* matches all packages in that scope. + +For each transitive dependency, the script prints: + - Which of our monorepo packages depend on it directly, and at what version range. + - Which tracked direct dependencies also depend on it, and at what version range. + +This lets you verify that your first-party packages and your tracked dependencies +all require the same version of a shared transitive dependency. +`; + +interface Config { + deps: string[]; + transitiveDeps: string[]; +} + +async function loadConfig(args: ParsedArgs): Promise { + const configPath = + typeof args.config === 'string' + ? path.resolve(process.cwd(), args.config) + : path.join(process.cwd(), '.check-transitive-deps.json'); + + let fileConfig: Partial = {}; + + try { + fileConfig = JSON.parse(await fs.readFile(configPath, 'utf8')); + } catch (e: any) { + if (e.code !== 'ENOENT' || args.config) { + throw e; + } + } + + const deps = + typeof args.deps === 'string' + ? args.deps.split(',').map((s: string) => s.trim()) + : Array.isArray(args.deps) + ? args.deps + : fileConfig.deps || []; + + const transitiveDeps = + typeof args['transitive-deps'] === 'string' + ? args['transitive-deps'].split(',').map((s: string) => s.trim()) + : Array.isArray(args['transitive-deps']) + ? args['transitive-deps'] + : fileConfig.transitiveDeps || []; + + return { deps, transitiveDeps }; +} + +function matchesAnyPattern(name: string, patterns: string[]): boolean { + return patterns.some((pattern) => { + const regex = new RegExp( + '^' + + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + + '$', + ); + return regex.test(name); + }); +} + +function getDepsFromPackageJson( + packageJson: Record, +): Map { + const deps = new Map(); + for (const group of DEPENDENCY_GROUPS) { + for (const [name, version] of Object.entries( + (packageJson[group] || {}) as Record, + )) { + if (!deps.has(name)) { + deps.set(name, version); + } + } + } + return deps; +} + +async function main(args: ParsedArgs) { + if (args.help) { + console.log(USAGE); + return; + } + + const config = await loadConfig(args); + + if (config.deps.length === 0 || config.transitiveDeps.length === 0) { + console.error( + 'Both --deps (or deps in config) and --transitive-deps (or transitiveDeps in config) must be provided and non-empty.', + ); + process.exitCode = 1; + return; + } + + // Our packages that directly depend on a transitive dep we care about. + // transitiveDep → [{ packageName, version }] + const ourDirectUsage = new Map< + string, + Array<{ packageName: string; version: string }> + >(); + + // Tracked direct deps and the version ranges our packages use for them. + // trackedDep → Set + const trackedDepRanges = new Map>(); + + for await (const { packageJson } of listAllPackages()) { + const packageName: string = packageJson.name; + const deps = getDepsFromPackageJson(packageJson); + + for (const [depName, version] of deps) { + if (matchesAnyPattern(depName, config.transitiveDeps)) { + let entry = ourDirectUsage.get(depName); + if (!entry) { + entry = []; + ourDirectUsage.set(depName, entry); + } + entry.push({ packageName, version }); + } + + if (matchesAnyPattern(depName, config.deps)) { + let entry = trackedDepRanges.get(depName); + if (!entry) { + entry = new Set(); + trackedDepRanges.set(depName, entry); + } + entry.add(version); + } + } + } + + // For each tracked direct dep (at each version range used), resolve its package.json + // and collect any transitive deps we care about. + // transitiveDep → Map<"trackedDep@range", versionOfTransitiveDep> + const viaTrackedDep = new Map>(); + + for (const [trackedDepName, versionRanges] of trackedDepRanges) { + for (const versionRange of versionRanges) { + let resolvedManifest: Record; + try { + resolvedManifest = await pacote.manifest( + `${trackedDepName}@${versionRange}`, + ); + } catch (e: any) { + console.error( + `Warning: could not resolve ${trackedDepName}@${versionRange}: ${e.message as string}`, + ); + continue; + } + + const trackedDepDeps = getDepsFromPackageJson(resolvedManifest); + for (const [depName, version] of trackedDepDeps) { + if (matchesAnyPattern(depName, config.transitiveDeps)) { + let entry = viaTrackedDep.get(depName); + if (!entry) { + entry = new Map(); + viaTrackedDep.set(depName, entry); + } + entry.set(`${trackedDepName}@${versionRange}`, version); + } + } + } + } + + const allTransitiveDeps = new Set([ + ...ourDirectUsage.keys(), + ...viaTrackedDep.keys(), + ]); + + if (allTransitiveDeps.size === 0) { + console.log( + '%s', + chalk.green( + 'No transitive dependencies found matching the provided allow lists.', + ), + ); + return; + } + + let foundMismatches = false; + for (const transitiveDep of [...allTransitiveDeps].sort()) { + const directUsages = ourDirectUsage.get(transitiveDep) ?? []; + const trackedUsages: [string, string][] = [ + ...(viaTrackedDep.get(transitiveDep) ?? new Map()), + ]; + + const allVersions = [ + ...directUsages.map((u) => u.version), + ...trackedUsages.map(([, v]) => v), + ]; + + const uniqueVersions = new Set(allVersions); + if (uniqueVersions.size <= 1) { + continue; + } + + foundMismatches = true; + const versionPad = Math.max(...allVersions.map((v) => v.length)); + + console.log(chalk.bold(transitiveDep)); + console.log(); + + for (const { packageName, version } of directUsages) { + console.log( + ' %s%s %s', + ' '.repeat(versionPad - version.length), + version, + chalk.dim(packageName), + ); + } + + for (const [trackedDepRef, version] of trackedUsages) { + console.log( + ' %s%s %s', + ' '.repeat(versionPad - version.length), + version, + chalk.dim(`via ${trackedDepRef}`), + ); + } + + console.log(); + } + + if (!foundMismatches) { + console.log( + '%s', + chalk.green( + 'All transitive dependencies are aligned, nothing to report!', + ), + ); + } +} + +process.on('unhandledRejection', (err: Error) => { + console.error(); + console.error(err?.stack || err?.message || err); + process.exitCode = 1; +}); + +main(minimist(process.argv.slice(2))).catch((err) => + process.nextTick(() => { + throw err; + }), +); From 25e2d6ece662ecf9d6c014bf2507f382d899c245 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:11:10 +0100 Subject: [PATCH 02/15] improvements --- .../src/check-transitive-deps.ts | 108 ++++++++++++++++-- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 8a4e3eea..3e0ea560 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -5,7 +5,9 @@ import path from 'path'; import { promises as fs } from 'fs'; import chalk from 'chalk'; import pacote from 'pacote'; +import semver from 'semver'; import { listAllPackages } from './utils/list-all-packages'; +import { getHighestRange } from './utils/semver-helpers'; import minimist from 'minimist'; import type { ParsedArgs } from 'minimist'; @@ -18,13 +20,14 @@ const DEPENDENCY_GROUPS = [ const USAGE = `Check transitive dependencies for version alignment. -USAGE: check-transitive-deps.js [--deps ] [--transitive-deps ] [--config ] +USAGE: check-transitive-deps.js [--deps ] [--transitive-deps ] [--config ] [--ignore-dev-deps] Options: - --deps Comma-separated list of direct dependencies to track. - --transitive-deps Comma-separated list of transitive dependencies to check alignment for. - --config Path to config file. Default is .check-transitive-deps.json + --deps Comma-separated list of direct dependencies to track. + --transitive-deps Comma-separated list of transitive dependencies to check alignment for. + --config Path to config file. Default is .check-transitive-deps.json + --ignore-dev-deps Ignore devDependencies when scanning both our own packages and tracked dependencies. Config file format (.check-transitive-deps.json): { @@ -81,6 +84,20 @@ async function loadConfig(args: ParsedArgs): Promise { return { deps, transitiveDeps }; } +function satisfiesHighest( + range: string, + highestVersion: string | null, +): boolean | null { + if (!highestVersion) return null; + try { + const minVer = semver.minVersion(highestVersion); + if (!minVer) return null; + return semver.satisfies(minVer, range); + } catch { + return null; + } +} + function matchesAnyPattern(name: string, patterns: string[]): boolean { return patterns.some((pattern) => { const regex = new RegExp( @@ -94,9 +111,13 @@ function matchesAnyPattern(name: string, patterns: string[]): boolean { function getDepsFromPackageJson( packageJson: Record, + { ignoreDevDeps = false }: { ignoreDevDeps?: boolean } = {}, ): Map { const deps = new Map(); for (const group of DEPENDENCY_GROUPS) { + if (ignoreDevDeps && group === 'devDependencies') { + continue; + } for (const [name, version] of Object.entries( (packageJson[group] || {}) as Record, )) { @@ -135,9 +156,16 @@ async function main(args: ParsedArgs) { // trackedDep → Set const trackedDepRanges = new Map>(); + const ignoreDevDeps: boolean = args['ignore-dev-deps'] === true; + + // Collect local monorepo package.json objects so we can resolve their deps + // locally instead of hitting the npm registry (they may be private / unpublished). + const localPackages = new Map>(); + for await (const { packageJson } of listAllPackages()) { const packageName: string = packageJson.name; - const deps = getDepsFromPackageJson(packageJson); + localPackages.set(packageName, packageJson); + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); for (const [depName, version] of deps) { if (matchesAnyPattern(depName, config.transitiveDeps)) { @@ -160,12 +188,38 @@ async function main(args: ParsedArgs) { } } + // Packages that live inside the monorepo are never external deps — remove any + // that crept into trackedDepRanges because one of our packages depends on them. + for (const localPkgName of localPackages.keys()) { + trackedDepRanges.delete(localPkgName); + } + // For each tracked direct dep (at each version range used), resolve its package.json // and collect any transitive deps we care about. // transitiveDep → Map<"trackedDep@range", versionOfTransitiveDep> const viaTrackedDep = new Map>(); for (const [trackedDepName, versionRanges] of trackedDepRanges) { + // For local monorepo packages, resolve deps from the local package.json + // instead of hitting the npm registry (they may be private / unpublished). + const localPkg = localPackages.get(trackedDepName); + if (localPkg) { + const trackedDepDeps = getDepsFromPackageJson(localPkg, { + ignoreDevDeps, + }); + for (const [depName, version] of trackedDepDeps) { + if (matchesAnyPattern(depName, config.transitiveDeps)) { + let entry = viaTrackedDep.get(depName); + if (!entry) { + entry = new Map(); + viaTrackedDep.set(depName, entry); + } + entry.set(trackedDepName, version); + } + } + continue; + } + for (const versionRange of versionRanges) { let resolvedManifest: Record; try { @@ -179,8 +233,16 @@ async function main(args: ParsedArgs) { continue; } - const trackedDepDeps = getDepsFromPackageJson(resolvedManifest); + const trackedDepDeps = getDepsFromPackageJson(resolvedManifest, { + ignoreDevDeps, + }); for (const [depName, version] of trackedDepDeps) { + // We're only interested in deps that we use ourselves. + // TODO: Do we want this? + //if (!ourDirectUsage.has(depName)) { + // continue; + //} + if (matchesAnyPattern(depName, config.transitiveDeps)) { let entry = viaTrackedDep.get(depName); if (!entry) { @@ -209,6 +271,7 @@ async function main(args: ParsedArgs) { } let foundMismatches = false; + const misaligned: string[] = []; for (const transitiveDep of [...allTransitiveDeps].sort()) { const directUsages = ourDirectUsage.get(transitiveDep) ?? []; const trackedUsages: [string, string][] = [ @@ -226,14 +289,29 @@ async function main(args: ParsedArgs) { } foundMismatches = true; + const highestVersion = getHighestRange(allVersions); + const hasMisaligned = allVersions.some( + (v) => satisfiesHighest(v, highestVersion) === false, + ); + if (hasMisaligned) { + misaligned.push(transitiveDep); + } const versionPad = Math.max(...allVersions.map((v) => v.length)); - console.log(chalk.bold(transitiveDep)); + console.log( + '%s %s', + chalk.bold(transitiveDep), + chalk.dim(`highest: ${highestVersion ?? 'unknown'}`), + ); console.log(); for (const { packageName, version } of directUsages) { + const match = satisfiesHighest(version, highestVersion); + const indicator = + match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); console.log( - ' %s%s %s', + '%s %s%s %s', + indicator, ' '.repeat(versionPad - version.length), version, chalk.dim(packageName), @@ -241,8 +319,12 @@ async function main(args: ParsedArgs) { } for (const [trackedDepRef, version] of trackedUsages) { + const match = satisfiesHighest(version, highestVersion); + const indicator = + match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); console.log( - ' %s%s %s', + '%s %s%s %s', + indicator, ' '.repeat(versionPad - version.length), version, chalk.dim(`via ${trackedDepRef}`), @@ -259,6 +341,14 @@ async function main(args: ParsedArgs) { 'All transitive dependencies are aligned, nothing to report!', ), ); + } else if (misaligned.length > 0) { + console.log(chalk.bold.red('Misaligned transitive dependencies:')); + console.log(); + for (const dep of misaligned) { + console.log(' %s', dep); + } + console.log(); + process.exitCode = 1; } } From 967968d96e159f36cc85e2766f6eb86aa2cb5141 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:27:13 +0100 Subject: [PATCH 03/15] minor cleanup --- .../src/check-transitive-deps.ts | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 3e0ea560..8168c9ed 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -273,29 +273,33 @@ async function main(args: ParsedArgs) { let foundMismatches = false; const misaligned: string[] = []; for (const transitiveDep of [...allTransitiveDeps].sort()) { - const directUsages = ourDirectUsage.get(transitiveDep) ?? []; - const trackedUsages: [string, string][] = [ - ...(viaTrackedDep.get(transitiveDep) ?? new Map()), - ]; - - const allVersions = [ - ...directUsages.map((u) => u.version), - ...trackedUsages.map(([, v]) => v), + const entries = [ + ...(ourDirectUsage.get(transitiveDep) ?? []).map( + ({ packageName, version }) => ({ version, label: packageName }), + ), + ...[...(viaTrackedDep.get(transitiveDep) ?? new Map())].map( + ([trackedDepRef, version]) => ({ + version, + label: `via ${trackedDepRef}`, + }), + ), ]; - const uniqueVersions = new Set(allVersions); + const uniqueVersions = new Set(entries.map((e) => e.version)); if (uniqueVersions.size <= 1) { continue; } foundMismatches = true; + const allVersions = entries.map((e) => e.version); const highestVersion = getHighestRange(allVersions); - const hasMisaligned = allVersions.some( - (v) => satisfiesHighest(v, highestVersion) === false, - ); - if (hasMisaligned) { + + if ( + entries.some((e) => satisfiesHighest(e.version, highestVersion) === false) + ) { misaligned.push(transitiveDep); } + const versionPad = Math.max(...allVersions.map((v) => v.length)); console.log( @@ -305,20 +309,7 @@ async function main(args: ParsedArgs) { ); console.log(); - for (const { packageName, version } of directUsages) { - const match = satisfiesHighest(version, highestVersion); - const indicator = - match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); - console.log( - '%s %s%s %s', - indicator, - ' '.repeat(versionPad - version.length), - version, - chalk.dim(packageName), - ); - } - - for (const [trackedDepRef, version] of trackedUsages) { + for (const { version, label } of entries) { const match = satisfiesHighest(version, highestVersion); const indicator = match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); @@ -327,7 +318,7 @@ async function main(args: ParsedArgs) { indicator, ' '.repeat(versionPad - version.length), version, - chalk.dim(`via ${trackedDepRef}`), + chalk.dim(label), ); } From a111bbeadf16eee6a26893513493c991eb2ffb06 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:34:38 +0100 Subject: [PATCH 04/15] more minor refactoring --- .../src/check-transitive-deps.ts | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 8168c9ed..a96ed67e 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -109,6 +109,32 @@ function matchesAnyPattern(name: string, patterns: string[]): boolean { }); } +function collectTransitiveDeps( + packageJson: Record, + label: string, + { + ignoreDevDeps, + transitiveDeps, + viaTrackedDep, + }: { + ignoreDevDeps: boolean; + transitiveDeps: string[]; + viaTrackedDep: Map>; + }, +) { + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + for (const [depName, version] of deps) { + if (matchesAnyPattern(depName, transitiveDeps)) { + let entry = viaTrackedDep.get(depName); + if (!entry) { + entry = new Map(); + viaTrackedDep.set(depName, entry); + } + entry.set(label, version); + } + } +} + function getDepsFromPackageJson( packageJson: Record, { ignoreDevDeps = false }: { ignoreDevDeps?: boolean } = {}, @@ -199,24 +225,18 @@ async function main(args: ParsedArgs) { // transitiveDep → Map<"trackedDep@range", versionOfTransitiveDep> const viaTrackedDep = new Map>(); + const collectOpts = { + ignoreDevDeps, + transitiveDeps: config.transitiveDeps, + viaTrackedDep, + }; + for (const [trackedDepName, versionRanges] of trackedDepRanges) { // For local monorepo packages, resolve deps from the local package.json // instead of hitting the npm registry (they may be private / unpublished). const localPkg = localPackages.get(trackedDepName); if (localPkg) { - const trackedDepDeps = getDepsFromPackageJson(localPkg, { - ignoreDevDeps, - }); - for (const [depName, version] of trackedDepDeps) { - if (matchesAnyPattern(depName, config.transitiveDeps)) { - let entry = viaTrackedDep.get(depName); - if (!entry) { - entry = new Map(); - viaTrackedDep.set(depName, entry); - } - entry.set(trackedDepName, version); - } - } + collectTransitiveDeps(localPkg, trackedDepName, collectOpts); continue; } @@ -233,25 +253,11 @@ async function main(args: ParsedArgs) { continue; } - const trackedDepDeps = getDepsFromPackageJson(resolvedManifest, { - ignoreDevDeps, - }); - for (const [depName, version] of trackedDepDeps) { - // We're only interested in deps that we use ourselves. - // TODO: Do we want this? - //if (!ourDirectUsage.has(depName)) { - // continue; - //} - - if (matchesAnyPattern(depName, config.transitiveDeps)) { - let entry = viaTrackedDep.get(depName); - if (!entry) { - entry = new Map(); - viaTrackedDep.set(depName, entry); - } - entry.set(`${trackedDepName}@${versionRange}`, version); - } - } + collectTransitiveDeps( + resolvedManifest, + `${trackedDepName}@${versionRange}`, + collectOpts, + ); } } @@ -278,7 +284,7 @@ async function main(args: ParsedArgs) { ({ packageName, version }) => ({ version, label: packageName }), ), ...[...(viaTrackedDep.get(transitiveDep) ?? new Map())].map( - ([trackedDepRef, version]) => ({ + ([trackedDepRef, version]: [string, string]) => ({ version, label: `via ${trackedDepRef}`, }), From e25089d0d543b8b726c47a6391e6edb420badf06 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:43:51 +0100 Subject: [PATCH 05/15] some unit tests and a fix --- .../src/check-transitive-deps.spec.ts | 124 ++++++++++++++++++ .../src/check-transitive-deps.ts | 19 +-- 2 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 packages/monorepo-tools/src/check-transitive-deps.spec.ts diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts new file mode 100644 index 00000000..ec7d8024 --- /dev/null +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -0,0 +1,124 @@ +import assert from 'assert'; +import { + matchesAnyPattern, + satisfiesHighest, + getDepsFromPackageJson, +} from './check-transitive-deps'; + +describe('check-transitive-deps', function () { + describe('matchesAnyPattern', function () { + it('matches an exact package name', function () { + assert.equal(matchesAnyPattern('foo', ['foo']), true); + }); + + it('does not match a different name', function () { + assert.equal(matchesAnyPattern('bar', ['foo']), false); + }); + + it('matches a scoped wildcard', function () { + assert.equal( + matchesAnyPattern('@mongodb-js/foo', ['@mongodb-js/*']), + true, + ); + assert.equal( + matchesAnyPattern('@mongodb-js/bar', ['@mongodb-js/*']), + true, + ); + }); + + it('does not match a different scope with a scoped wildcard', function () { + assert.equal( + matchesAnyPattern('@other-scope/foo', ['@mongodb-js/*']), + false, + ); + }); + + it('does not let * match across a slash', function () { + assert.equal( + matchesAnyPattern('@mongodb-js/foo/bar', ['@mongodb-js/*']), + false, + ); + }); + + it('matches a prefix wildcard', function () { + assert.equal(matchesAnyPattern('mongodb-foo', ['mongodb-*']), true); + assert.equal(matchesAnyPattern('mongodb-bar', ['mongodb-*']), true); + }); + + it('does not match when prefix does not fit', function () { + assert.equal(matchesAnyPattern('other-foo', ['mongodb-*']), false); + }); + + it('matches against any pattern in the list', function () { + assert.equal( + matchesAnyPattern('foo', ['bar', '@mongodb-js/*', 'foo']), + true, + ); + }); + + it('returns false for an empty pattern list', function () { + assert.equal(matchesAnyPattern('foo', []), false); + }); + + it('escapes regex special characters in patterns', function () { + assert.equal(matchesAnyPattern('foo.bar', ['foo.bar']), true); + assert.equal(matchesAnyPattern('fooXbar', ['foo.bar']), false); + }); + }); + + describe('satisfiesHighest', function () { + it('returns true when the range includes the highest minimum version', function () { + assert.equal(satisfiesHighest('^1.0.0', '^1.5.0'), true); + }); + + it('returns false when the range excludes the highest minimum version', function () { + assert.equal(satisfiesHighest('^1.0.0 <1.3.0', '^1.5.0'), false); + }); + + it('returns true when the range equals the highest', function () { + assert.equal(satisfiesHighest('^1.5.0', '^1.5.0'), true); + }); + + it('returns null when highestVersion is null', function () { + assert.equal(satisfiesHighest('^1.0.0', null), null); + }); + + it('returns null for an invalid range', function () { + assert.equal(satisfiesHighest('not-a-version', '^1.0.0'), null); + }); + }); + + describe('getDepsFromPackageJson', function () { + const packageJson = { + dependencies: { foo: '^1.0.0', shared: '^2.0.0' }, + devDependencies: { bar: '^3.0.0', shared: '^2.1.0' }, + peerDependencies: { baz: '^4.0.0' }, + }; + + it('collects deps from all groups by default', function () { + const deps = getDepsFromPackageJson(packageJson); + assert.equal(deps.get('foo'), '^1.0.0'); + assert.equal(deps.get('bar'), '^3.0.0'); + assert.equal(deps.get('baz'), '^4.0.0'); + }); + + it('excludes devDependencies when ignoreDevDeps is true', function () { + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps: true }); + assert.equal(deps.get('foo'), '^1.0.0'); + assert.equal(deps.get('bar'), undefined); + assert.equal(deps.get('baz'), '^4.0.0'); + }); + + it('gives precedence to the first group when a name appears in multiple groups', function () { + // 'shared' appears in both dependencies and devDependencies; + // dependencies is iterated first so its version wins. + const deps = getDepsFromPackageJson(packageJson); + assert.equal(deps.get('shared'), '^2.0.0'); + }); + + it('handles missing dependency groups gracefully', function () { + const deps = getDepsFromPackageJson({ name: 'empty-pkg' }); + assert.equal(deps.size, 0); + }); + }); +}); diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index a96ed67e..9fb6457f 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -84,11 +84,12 @@ async function loadConfig(args: ParsedArgs): Promise { return { deps, transitiveDeps }; } -function satisfiesHighest( +export function satisfiesHighest( range: string, highestVersion: string | null, ): boolean | null { if (!highestVersion) return null; + if (!semver.validRange(range)) return null; try { const minVer = semver.minVersion(highestVersion); if (!minVer) return null; @@ -98,7 +99,7 @@ function satisfiesHighest( } } -function matchesAnyPattern(name: string, patterns: string[]): boolean { +export function matchesAnyPattern(name: string, patterns: string[]): boolean { return patterns.some((pattern) => { const regex = new RegExp( '^' + @@ -135,7 +136,7 @@ function collectTransitiveDeps( } } -function getDepsFromPackageJson( +export function getDepsFromPackageJson( packageJson: Record, { ignoreDevDeps = false }: { ignoreDevDeps?: boolean } = {}, ): Map { @@ -355,8 +356,10 @@ process.on('unhandledRejection', (err: Error) => { process.exitCode = 1; }); -main(minimist(process.argv.slice(2))).catch((err) => - process.nextTick(() => { - throw err; - }), -); +if (require.main === module) { + main(minimist(process.argv.slice(2))).catch((err) => + process.nextTick(() => { + throw err; + }), + ); +} From 1395dbcca188f34f57b71b3d6ca378f37efd854e Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:49:22 +0100 Subject: [PATCH 06/15] refactor, more tests --- .../src/check-transitive-deps.spec.ts | 130 ++++++++++ .../src/check-transitive-deps.ts | 236 +++++++++--------- 2 files changed, 246 insertions(+), 120 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts index ec7d8024..e3d18c21 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -3,6 +3,7 @@ import { matchesAnyPattern, satisfiesHighest, getDepsFromPackageJson, + gatherTransitiveDepsInfo, } from './check-transitive-deps'; describe('check-transitive-deps', function () { @@ -88,6 +89,135 @@ describe('check-transitive-deps', function () { }); }); + describe('gatherTransitiveDepsInfo', function () { + const config = { + deps: ['tracked-dep'], + transitiveDeps: ['shared-lib'], + }; + + // Builds a stub resolveExternal that returns the given manifest. + function resolverFor(manifest: Record) { + return async (_name: string, _range: string) => Promise.resolve(manifest); + } + + it('finds a transitive dep used both directly and via a tracked dep', async function () { + const packages = [ + { + packageJson: { + name: '@my-scope/pkg-a', + dependencies: { 'shared-lib': '^1.0.0', 'tracked-dep': '^2.0.0' }, + }, + }, + ]; + + const trackedDepManifest = { + name: 'tracked-dep', + dependencies: { 'shared-lib': '^1.5.0' }, + }; + + const groups = await gatherTransitiveDepsInfo(config, { + ignoreDevDeps: false, + packages, + resolveExternal: resolverFor(trackedDepManifest), + }); + + const entries = groups.get('shared-lib'); + assert.ok(entries, 'shared-lib should be in the result'); + + const versions = entries.map((e) => ({ + version: e.version, + label: e.label, + })); + assert.deepStrictEqual(versions, [ + { version: '^1.0.0', label: '@my-scope/pkg-a' }, + { version: '^1.5.0', label: 'via tracked-dep@^2.0.0' }, + ]); + }); + + it('excludes devDependencies when ignoreDevDeps is true', async function () { + const packages = [ + { + packageJson: { + name: 'pkg-a', + devDependencies: { + 'shared-lib': '^1.0.0', + 'tracked-dep': '^2.0.0', + }, + }, + }, + ]; + + const trackedDepManifest = { + name: 'tracked-dep', + devDependencies: { 'shared-lib': '^1.5.0' }, + }; + + const groups = await gatherTransitiveDepsInfo(config, { + ignoreDevDeps: true, + packages, + resolveExternal: resolverFor(trackedDepManifest), + }); + + assert.equal(groups.size, 0, 'dev deps should be ignored'); + }); + + it('does not treat local monorepo packages as external tracked deps', async function () { + const packages = [ + { + packageJson: { + name: 'tracked-dep', // this package IS in the monorepo + dependencies: { 'shared-lib': '^1.0.0' }, + }, + }, + { + packageJson: { + name: 'pkg-a', + dependencies: { 'tracked-dep': '^1.0.0' }, + }, + }, + ]; + + let externalCallCount = 0; + const groups = await gatherTransitiveDepsInfo(config, { + ignoreDevDeps: false, + packages, + resolveExternal: async () => { + externalCallCount++; + return Promise.resolve({}); + }, + }); + + assert.equal( + externalCallCount, + 0, + 'resolveExternal should not be called for local packages', + ); + // shared-lib is a direct dep of tracked-dep (local), so it appears via the local scan + const entries = groups.get('shared-lib'); + assert.ok(entries); + assert.equal(entries[0].label, 'tracked-dep'); + }); + + it('returns an empty map when nothing matches', async function () { + const packages = [ + { + packageJson: { + name: 'pkg-a', + dependencies: { 'unrelated-dep': '^1.0.0' }, + }, + }, + ]; + + const groups = await gatherTransitiveDepsInfo(config, { + ignoreDevDeps: false, + packages, + resolveExternal: resolverFor({}), + }); + + assert.equal(groups.size, 0); + }); + }); + describe('getDepsFromPackageJson', function () { const packageJson = { dependencies: { foo: '^1.0.0', shared: '^2.0.0' }, diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 9fb6457f..68bb1ed2 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -110,30 +110,121 @@ export function matchesAnyPattern(name: string, patterns: string[]): boolean { }); } -function collectTransitiveDeps( - packageJson: Record, - label: string, +export interface TransitiveDepsEntry { + version: string; + label: string; +} + +// Returns a map from transitive dep name → all usages found across our packages +// and tracked external dependencies. Includes deps with only one unique version; +// callers decide whether to filter those out. +export async function gatherTransitiveDepsInfo( + config: Config, { ignoreDevDeps, - transitiveDeps, - viaTrackedDep, + packages, + resolveExternal, }: { ignoreDevDeps: boolean; - transitiveDeps: string[]; - viaTrackedDep: Map>; + packages: + | AsyncIterable<{ packageJson: Record }> + | Iterable<{ packageJson: Record }>; + resolveExternal: ( + name: string, + versionRange: string, + ) => Promise>; }, -) { - const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); - for (const [depName, version] of deps) { - if (matchesAnyPattern(depName, transitiveDeps)) { - let entry = viaTrackedDep.get(depName); - if (!entry) { - entry = new Map(); - viaTrackedDep.set(depName, entry); +): Promise> { + // transitiveDep → entries from our own packages that depend on it directly + const ourDirectUsage = new Map(); + + // external dep name → set of version ranges used across our packages + const trackedDepRanges = new Map>(); + + // local package names → their package.json (to avoid npm lookups) + const localPackages = new Map>(); + + for await (const { packageJson } of packages) { + const packageName: string = packageJson.name; + localPackages.set(packageName, packageJson); + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + + for (const [depName, version] of deps) { + if (matchesAnyPattern(depName, config.transitiveDeps)) { + let entry = ourDirectUsage.get(depName); + if (!entry) { + entry = []; + ourDirectUsage.set(depName, entry); + } + entry.push({ version, label: packageName }); + } + + if (matchesAnyPattern(depName, config.deps)) { + let ranges = trackedDepRanges.get(depName); + if (!ranges) { + ranges = new Set(); + trackedDepRanges.set(depName, ranges); + } + ranges.add(version); + } + } + } + + // Packages that live in the monorepo are not external deps. + for (const localPkgName of localPackages.keys()) { + trackedDepRanges.delete(localPkgName); + } + + // Start result with direct usages, then append indirect ones below. + const result = new Map(); + for (const [depName, entries] of ourDirectUsage) { + result.set(depName, [...entries]); + } + + for (const [trackedDepName, versionRanges] of trackedDepRanges) { + const manifests: Array<{ + label: string; + packageJson: Record; + }> = []; + + const localPkg = localPackages.get(trackedDepName); + if (localPkg) { + manifests.push({ label: trackedDepName, packageJson: localPkg }); + } else { + for (const versionRange of versionRanges) { + try { + const packageJson = await resolveExternal( + trackedDepName, + versionRange, + ); + manifests.push({ + label: `${trackedDepName}@${versionRange}`, + packageJson, + }); + } catch (e: any) { + console.error( + `Warning: could not resolve ${trackedDepName}@${versionRange}: ${e.message as string}`, + ); + } + } + } + + for (const { label, packageJson } of manifests) { + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + for (const [depName, version] of deps) { + if (matchesAnyPattern(depName, config.transitiveDeps)) { + let entry = result.get(depName); + if (!entry) { + entry = []; + result.set(depName, entry); + } + entry.push({ version, label: `via ${label}` }); + } } - entry.set(label, version); } } + + return result; } export function getDepsFromPackageJson( @@ -172,102 +263,16 @@ async function main(args: ParsedArgs) { return; } - // Our packages that directly depend on a transitive dep we care about. - // transitiveDep → [{ packageName, version }] - const ourDirectUsage = new Map< - string, - Array<{ packageName: string; version: string }> - >(); - - // Tracked direct deps and the version ranges our packages use for them. - // trackedDep → Set - const trackedDepRanges = new Map>(); - const ignoreDevDeps: boolean = args['ignore-dev-deps'] === true; - // Collect local monorepo package.json objects so we can resolve their deps - // locally instead of hitting the npm registry (they may be private / unpublished). - const localPackages = new Map>(); - - for await (const { packageJson } of listAllPackages()) { - const packageName: string = packageJson.name; - localPackages.set(packageName, packageJson); - const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); - - for (const [depName, version] of deps) { - if (matchesAnyPattern(depName, config.transitiveDeps)) { - let entry = ourDirectUsage.get(depName); - if (!entry) { - entry = []; - ourDirectUsage.set(depName, entry); - } - entry.push({ packageName, version }); - } - - if (matchesAnyPattern(depName, config.deps)) { - let entry = trackedDepRanges.get(depName); - if (!entry) { - entry = new Set(); - trackedDepRanges.set(depName, entry); - } - entry.add(version); - } - } - } - - // Packages that live inside the monorepo are never external deps — remove any - // that crept into trackedDepRanges because one of our packages depends on them. - for (const localPkgName of localPackages.keys()) { - trackedDepRanges.delete(localPkgName); - } - - // For each tracked direct dep (at each version range used), resolve its package.json - // and collect any transitive deps we care about. - // transitiveDep → Map<"trackedDep@range", versionOfTransitiveDep> - const viaTrackedDep = new Map>(); - - const collectOpts = { + const groups = await gatherTransitiveDepsInfo(config, { ignoreDevDeps, - transitiveDeps: config.transitiveDeps, - viaTrackedDep, - }; - - for (const [trackedDepName, versionRanges] of trackedDepRanges) { - // For local monorepo packages, resolve deps from the local package.json - // instead of hitting the npm registry (they may be private / unpublished). - const localPkg = localPackages.get(trackedDepName); - if (localPkg) { - collectTransitiveDeps(localPkg, trackedDepName, collectOpts); - continue; - } - - for (const versionRange of versionRanges) { - let resolvedManifest: Record; - try { - resolvedManifest = await pacote.manifest( - `${trackedDepName}@${versionRange}`, - ); - } catch (e: any) { - console.error( - `Warning: could not resolve ${trackedDepName}@${versionRange}: ${e.message as string}`, - ); - continue; - } - - collectTransitiveDeps( - resolvedManifest, - `${trackedDepName}@${versionRange}`, - collectOpts, - ); - } - } - - const allTransitiveDeps = new Set([ - ...ourDirectUsage.keys(), - ...viaTrackedDep.keys(), - ]); + packages: listAllPackages(), + resolveExternal: (name, versionRange) => + pacote.manifest(`${name}@${versionRange}`), + }); - if (allTransitiveDeps.size === 0) { + if (groups.size === 0) { console.log( '%s', chalk.green( @@ -279,18 +284,9 @@ async function main(args: ParsedArgs) { let foundMismatches = false; const misaligned: string[] = []; - for (const transitiveDep of [...allTransitiveDeps].sort()) { - const entries = [ - ...(ourDirectUsage.get(transitiveDep) ?? []).map( - ({ packageName, version }) => ({ version, label: packageName }), - ), - ...[...(viaTrackedDep.get(transitiveDep) ?? new Map())].map( - ([trackedDepRef, version]: [string, string]) => ({ - version, - label: `via ${trackedDepRef}`, - }), - ), - ]; + + for (const transitiveDep of [...groups.keys()].sort()) { + const entries = groups.get(transitiveDep)!; const uniqueVersions = new Set(entries.map((e) => e.version)); if (uniqueVersions.size <= 1) { From ee89317a949dfde6d627acced7dc1bf74bc59bf4 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 20:56:12 +0100 Subject: [PATCH 07/15] more refactoring, more tests --- .../src/check-transitive-deps.spec.ts | 18 +++--- .../src/check-transitive-deps.ts | 58 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts index e3d18c21..996014a7 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -90,9 +90,10 @@ describe('check-transitive-deps', function () { }); describe('gatherTransitiveDepsInfo', function () { - const config = { + const baseOpts = { deps: ['tracked-dep'], transitiveDeps: ['shared-lib'], + ignoreDevDeps: false, }; // Builds a stub resolveExternal that returns the given manifest. @@ -115,8 +116,8 @@ describe('check-transitive-deps', function () { dependencies: { 'shared-lib': '^1.5.0' }, }; - const groups = await gatherTransitiveDepsInfo(config, { - ignoreDevDeps: false, + const groups = await gatherTransitiveDepsInfo({ + ...baseOpts, packages, resolveExternal: resolverFor(trackedDepManifest), }); @@ -152,7 +153,8 @@ describe('check-transitive-deps', function () { devDependencies: { 'shared-lib': '^1.5.0' }, }; - const groups = await gatherTransitiveDepsInfo(config, { + const groups = await gatherTransitiveDepsInfo({ + ...baseOpts, ignoreDevDeps: true, packages, resolveExternal: resolverFor(trackedDepManifest), @@ -178,8 +180,8 @@ describe('check-transitive-deps', function () { ]; let externalCallCount = 0; - const groups = await gatherTransitiveDepsInfo(config, { - ignoreDevDeps: false, + const groups = await gatherTransitiveDepsInfo({ + ...baseOpts, packages, resolveExternal: async () => { externalCallCount++; @@ -208,8 +210,8 @@ describe('check-transitive-deps', function () { }, ]; - const groups = await gatherTransitiveDepsInfo(config, { - ignoreDevDeps: false, + const groups = await gatherTransitiveDepsInfo({ + ...baseOpts, packages, resolveExternal: resolverFor({}), }); diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 68bb1ed2..12d995c9 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -118,23 +118,25 @@ export interface TransitiveDepsEntry { // Returns a map from transitive dep name → all usages found across our packages // and tracked external dependencies. Includes deps with only one unique version; // callers decide whether to filter those out. -export async function gatherTransitiveDepsInfo( - config: Config, - { - ignoreDevDeps, - packages, - resolveExternal, - }: { - ignoreDevDeps: boolean; - packages: - | AsyncIterable<{ packageJson: Record }> - | Iterable<{ packageJson: Record }>; - resolveExternal: ( - name: string, - versionRange: string, - ) => Promise>; - }, -): Promise> { +export async function gatherTransitiveDepsInfo({ + deps, + transitiveDeps, + ignoreDevDeps, + packages, + resolveExternal, +}: { + deps: string[]; + transitiveDeps: string[]; + ignoreDevDeps: boolean; + packages: + | AsyncIterable<{ packageJson: Record }> + | Iterable<{ packageJson: Record }>; + resolveExternal: ( + name: string, + versionRange: string, + ) => Promise>; +}): Promise> { + const config = { deps, transitiveDeps }; // transitiveDep → entries from our own packages that depend on it directly const ourDirectUsage = new Map(); @@ -265,7 +267,8 @@ async function main(args: ParsedArgs) { const ignoreDevDeps: boolean = args['ignore-dev-deps'] === true; - const groups = await gatherTransitiveDepsInfo(config, { + const groups = await gatherTransitiveDepsInfo({ + ...config, ignoreDevDeps, packages: listAllPackages(), resolveExternal: (name, versionRange) => @@ -283,10 +286,13 @@ async function main(args: ParsedArgs) { } let foundMismatches = false; - const misaligned: string[] = []; + const misaligned = new Set(); for (const transitiveDep of [...groups.keys()].sort()) { - const entries = groups.get(transitiveDep)!; + const entries = groups.get(transitiveDep); + if (!entries) { + continue; + } const uniqueVersions = new Set(entries.map((e) => e.version)); if (uniqueVersions.size <= 1) { @@ -296,13 +302,6 @@ async function main(args: ParsedArgs) { foundMismatches = true; const allVersions = entries.map((e) => e.version); const highestVersion = getHighestRange(allVersions); - - if ( - entries.some((e) => satisfiesHighest(e.version, highestVersion) === false) - ) { - misaligned.push(transitiveDep); - } - const versionPad = Math.max(...allVersions.map((v) => v.length)); console.log( @@ -314,6 +313,9 @@ async function main(args: ParsedArgs) { for (const { version, label } of entries) { const match = satisfiesHighest(version, highestVersion); + if (match === false) { + misaligned.add(transitiveDep); + } const indicator = match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); console.log( @@ -335,7 +337,7 @@ async function main(args: ParsedArgs) { 'All transitive dependencies are aligned, nothing to report!', ), ); - } else if (misaligned.length > 0) { + } else if (misaligned.size > 0) { console.log(chalk.bold.red('Misaligned transitive dependencies:')); console.log(); for (const dep of misaligned) { From f1869a1d2ea04e53e7c023ba7ef6e20b28cb8ef8 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 21:09:26 +0100 Subject: [PATCH 08/15] claude suggestions --- .../src/check-transitive-deps.spec.ts | 114 ++++++++++++++++++ .../src/check-transitive-deps.ts | 94 ++++++++++----- 2 files changed, 176 insertions(+), 32 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts index 996014a7..57649cbb 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -4,6 +4,7 @@ import { satisfiesHighest, getDepsFromPackageJson, gatherTransitiveDepsInfo, + findMisalignments, } from './check-transitive-deps'; describe('check-transitive-deps', function () { @@ -220,6 +221,119 @@ describe('check-transitive-deps', function () { }); }); + describe('findMisalignments', function () { + it('returns empty array when all versions are aligned', function () { + const groups = new Map([ + [ + 'shared-lib', + [ + { version: '^1.0.0', label: 'pkg-a' }, + { version: '^1.0.0', label: 'pkg-b' }, + ], + ], + ]); + assert.deepStrictEqual(findMisalignments(groups), []); + }); + + it('returns empty array when a dep has only one entry', function () { + const groups = new Map([ + ['shared-lib', [{ version: '^1.0.0', label: 'pkg-a' }]], + ]); + assert.deepStrictEqual(findMisalignments(groups), []); + }); + + it('returns empty array for an empty map', function () { + assert.deepStrictEqual(findMisalignments(new Map()), []); + }); + + it('reports a mismatch when versions differ', function () { + const groups = new Map([ + [ + 'shared-lib', + [ + { version: '^1.0.0', label: 'pkg-a' }, + { version: '^2.0.0', label: 'via tracked-dep@^1.0.0' }, + ], + ], + ]); + + const result = findMisalignments(groups); + assert.equal(result.length, 1); + assert.equal(result[0].name, 'shared-lib'); + assert.equal(result[0].highestVersion, '^2.0.0'); + assert.equal(result[0].entries.length, 2); + }); + + it('marks entries that do not satisfy the highest range', function () { + const groups = new Map([ + [ + 'shared-lib', + [ + { version: '^1.0.0', label: 'pkg-a' }, + { version: '^2.0.0', label: 'pkg-b' }, + ], + ], + ]); + + const entries = findMisalignments(groups)[0].entries; + assert.equal(entries[0].satisfiesHighest, false); + assert.equal(entries[1].satisfiesHighest, true); + }); + + it('marks entries that satisfy the highest range', function () { + const groups = new Map([ + [ + 'shared-lib', + [ + { version: '^1.0.0', label: 'pkg-a' }, + { version: '^1.5.0', label: 'pkg-b' }, + ], + ], + ]); + + const entries = findMisalignments(groups)[0].entries; + assert.equal(entries[0].satisfiesHighest, true); + assert.equal(entries[1].satisfiesHighest, true); + }); + + it('returns results sorted by dep name', function () { + const groups = new Map([ + [ + 'zlib', + [ + { version: '^1.0.0', label: 'a' }, + { version: '^2.0.0', label: 'b' }, + ], + ], + [ + 'axios', + [ + { version: '^0.21.0', label: 'a' }, + { version: '^1.0.0', label: 'b' }, + ], + ], + ]); + + const names = findMisalignments(groups).map((m) => m.name); + assert.deepStrictEqual(names, ['axios', 'zlib']); + }); + + it('sets satisfiesHighest to null for invalid ranges', function () { + const groups = new Map([ + [ + 'shared-lib', + [ + { version: 'not-valid', label: 'pkg-a' }, + { version: '^1.0.0', label: 'pkg-b' }, + ], + ], + ]); + + const entries = findMisalignments(groups)[0].entries; + assert.equal(entries[0].satisfiesHighest, null); + }); + }); + describe('getDepsFromPackageJson', function () { const packageJson = { dependencies: { foo: '^1.0.0', shared: '^2.0.0' }, diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 12d995c9..30b8084c 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -149,9 +149,9 @@ export async function gatherTransitiveDepsInfo({ for await (const { packageJson } of packages) { const packageName: string = packageJson.name; localPackages.set(packageName, packageJson); - const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + const pkgDeps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); - for (const [depName, version] of deps) { + for (const [depName, version] of pkgDeps) { if (matchesAnyPattern(depName, config.transitiveDeps)) { let entry = ourDirectUsage.get(depName); if (!entry) { @@ -249,6 +249,47 @@ export function getDepsFromPackageJson( return deps; } +export interface MismatchEntry { + version: string; + label: string; + satisfiesHighest: boolean | null; +} + +export interface Mismatch { + name: string; + highestVersion: string | null; + entries: MismatchEntry[]; +} + +export function findMisalignments( + groups: Map, +): Mismatch[] { + const mismatches: Mismatch[] = []; + + for (const name of [...groups.keys()].sort()) { + const entries = groups.get(name)!; + const uniqueVersions = new Set(entries.map((e) => e.version)); + if (uniqueVersions.size <= 1) { + continue; + } + + const allVersions = entries.map((e) => e.version); + const highestVersion = getHighestRange(allVersions); + + mismatches.push({ + name, + highestVersion, + entries: entries.map((e) => ({ + version: e.version, + label: e.label, + satisfiesHighest: satisfiesHighest(e.version, highestVersion), + })), + }); + } + + return mismatches; +} + async function main(args: ParsedArgs) { if (args.help) { console.log(USAGE); @@ -285,37 +326,29 @@ async function main(args: ParsedArgs) { return; } - let foundMismatches = false; - const misaligned = new Set(); - - for (const transitiveDep of [...groups.keys()].sort()) { - const entries = groups.get(transitiveDep); - if (!entries) { - continue; - } + const mismatches = findMisalignments(groups); - const uniqueVersions = new Set(entries.map((e) => e.version)); - if (uniqueVersions.size <= 1) { - continue; - } + if (mismatches.length === 0) { + console.log( + '%s', + chalk.green( + 'All transitive dependencies are aligned, nothing to report!', + ), + ); + return; + } - foundMismatches = true; - const allVersions = entries.map((e) => e.version); - const highestVersion = getHighestRange(allVersions); - const versionPad = Math.max(...allVersions.map((v) => v.length)); + for (const { name, highestVersion, entries } of mismatches) { + const versionPad = Math.max(...entries.map((e) => e.version.length)); console.log( '%s %s', - chalk.bold(transitiveDep), + chalk.bold(name), chalk.dim(`highest: ${highestVersion ?? 'unknown'}`), ); console.log(); - for (const { version, label } of entries) { - const match = satisfiesHighest(version, highestVersion); - if (match === false) { - misaligned.add(transitiveDep); - } + for (const { version, label, satisfiesHighest: match } of entries) { const indicator = match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); console.log( @@ -330,14 +363,11 @@ async function main(args: ParsedArgs) { console.log(); } - if (!foundMismatches) { - console.log( - '%s', - chalk.green( - 'All transitive dependencies are aligned, nothing to report!', - ), - ); - } else if (misaligned.size > 0) { + const misaligned = mismatches + .filter((m) => m.entries.some((e) => e.satisfiesHighest === false)) + .map((m) => m.name); + + if (misaligned.length > 0) { console.log(chalk.bold.red('Misaligned transitive dependencies:')); console.log(); for (const dep of misaligned) { From d5fdc1e93984d5d665a809783f58524628df6ccd Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 21:13:51 +0100 Subject: [PATCH 09/15] oops. the require.main guard never worked --- packages/monorepo-tools/src/check-transitive-deps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 30b8084c..121d9ce0 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -384,7 +384,7 @@ process.on('unhandledRejection', (err: Error) => { process.exitCode = 1; }); -if (require.main === module) { +if (process.argv[1]?.endsWith('check-transitive-deps.js')) { main(minimist(process.argv.slice(2))).catch((err) => process.nextTick(() => { throw err; From 52711bc790f1c24cf48cb617386488194f1e3b64 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 21:20:11 +0100 Subject: [PATCH 10/15] just one deps config --- .check-transitive-deps.json | 3 +- .../src/check-transitive-deps.spec.ts | 13 ++++++-- .../src/check-transitive-deps.ts | 32 ++++++------------- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/.check-transitive-deps.json b/.check-transitive-deps.json index 8553a0bb..50abe886 100644 --- a/.check-transitive-deps.json +++ b/.check-transitive-deps.json @@ -1,4 +1,3 @@ { - "deps": ["@mongodb-js/*", "mongodb", "mongodb-*"], - "transitiveDeps": ["@mongodb-js/*", "mongodb", "mongodb-*"] + "deps": ["@mongodb-js/*", "mongodb", "mongodb-*"] } diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts index 57649cbb..32ce2ae6 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -92,8 +92,7 @@ describe('check-transitive-deps', function () { describe('gatherTransitiveDepsInfo', function () { const baseOpts = { - deps: ['tracked-dep'], - transitiveDeps: ['shared-lib'], + deps: ['tracked-dep', 'shared-lib'], ignoreDevDeps: false, }; @@ -120,7 +119,10 @@ describe('check-transitive-deps', function () { const groups = await gatherTransitiveDepsInfo({ ...baseOpts, packages, - resolveExternal: resolverFor(trackedDepManifest), + resolveExternal: async (name, range) => { + if (name === 'tracked-dep') return trackedDepManifest; + return {}; + }, }); const entries = groups.get('shared-lib'); @@ -172,6 +174,11 @@ describe('check-transitive-deps', function () { dependencies: { 'shared-lib': '^1.0.0' }, }, }, + { + packageJson: { + name: 'shared-lib', // also in the monorepo + }, + }, { packageJson: { name: 'pkg-a', diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 121d9ce0..c3e396d6 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -20,19 +20,17 @@ const DEPENDENCY_GROUPS = [ const USAGE = `Check transitive dependencies for version alignment. -USAGE: check-transitive-deps.js [--deps ] [--transitive-deps ] [--config ] [--ignore-dev-deps] +USAGE: check-transitive-deps.js [--deps ] [--config ] [--ignore-dev-deps] Options: - --deps Comma-separated list of direct dependencies to track. - --transitive-deps Comma-separated list of transitive dependencies to check alignment for. + --deps Comma-separated list of dependencies to track. --config Path to config file. Default is .check-transitive-deps.json --ignore-dev-deps Ignore devDependencies when scanning both our own packages and tracked dependencies. Config file format (.check-transitive-deps.json): { - "deps": ["package-a", "@my-scope/*"], - "transitiveDeps": ["package-x", "package-y"] + "deps": ["package-a", "@my-scope/*"] } Glob patterns are supported: * matches any sequence of characters except /. @@ -48,7 +46,6 @@ all require the same version of a shared transitive dependency. interface Config { deps: string[]; - transitiveDeps: string[]; } async function loadConfig(args: ParsedArgs): Promise { @@ -74,14 +71,7 @@ async function loadConfig(args: ParsedArgs): Promise { ? args.deps : fileConfig.deps || []; - const transitiveDeps = - typeof args['transitive-deps'] === 'string' - ? args['transitive-deps'].split(',').map((s: string) => s.trim()) - : Array.isArray(args['transitive-deps']) - ? args['transitive-deps'] - : fileConfig.transitiveDeps || []; - - return { deps, transitiveDeps }; + return { deps }; } export function satisfiesHighest( @@ -120,13 +110,11 @@ export interface TransitiveDepsEntry { // callers decide whether to filter those out. export async function gatherTransitiveDepsInfo({ deps, - transitiveDeps, ignoreDevDeps, packages, resolveExternal, }: { deps: string[]; - transitiveDeps: string[]; ignoreDevDeps: boolean; packages: | AsyncIterable<{ packageJson: Record }> @@ -136,7 +124,7 @@ export async function gatherTransitiveDepsInfo({ versionRange: string, ) => Promise>; }): Promise> { - const config = { deps, transitiveDeps }; + const config = { deps }; // transitiveDep → entries from our own packages that depend on it directly const ourDirectUsage = new Map(); @@ -152,7 +140,7 @@ export async function gatherTransitiveDepsInfo({ const pkgDeps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); for (const [depName, version] of pkgDeps) { - if (matchesAnyPattern(depName, config.transitiveDeps)) { + if (matchesAnyPattern(depName, config.deps)) { let entry = ourDirectUsage.get(depName); if (!entry) { entry = []; @@ -214,7 +202,7 @@ export async function gatherTransitiveDepsInfo({ for (const { label, packageJson } of manifests) { const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); for (const [depName, version] of deps) { - if (matchesAnyPattern(depName, config.transitiveDeps)) { + if (matchesAnyPattern(depName, config.deps)) { let entry = result.get(depName); if (!entry) { entry = []; @@ -298,10 +286,8 @@ async function main(args: ParsedArgs) { const config = await loadConfig(args); - if (config.deps.length === 0 || config.transitiveDeps.length === 0) { - console.error( - 'Both --deps (or deps in config) and --transitive-deps (or transitiveDeps in config) must be provided and non-empty.', - ); + if (config.deps.length === 0) { + console.error('--deps (or deps in config) must be provided and non-empty.'); process.exitCode = 1; return; } From 8d74e7815e4b34044fe0298909be09e71b1dd093 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 21:23:43 +0100 Subject: [PATCH 11/15] move the rejection handler into the run guard --- packages/monorepo-tools/src/check-transitive-deps.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index c3e396d6..97b87735 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -364,13 +364,13 @@ async function main(args: ParsedArgs) { } } -process.on('unhandledRejection', (err: Error) => { - console.error(); - console.error(err?.stack || err?.message || err); - process.exitCode = 1; -}); - if (process.argv[1]?.endsWith('check-transitive-deps.js')) { + process.on('unhandledRejection', (err: Error) => { + console.error(); + console.error(err?.stack || err?.message || err); + process.exitCode = 1; + }); + main(minimist(process.argv.slice(2))).catch((err) => process.nextTick(() => { throw err; From d57541533d7e5b0779ba1b2381743616f73b5ace Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Tue, 14 Apr 2026 21:25:19 +0100 Subject: [PATCH 12/15] update this repo's own config --- .check-transitive-deps.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.check-transitive-deps.json b/.check-transitive-deps.json index 50abe886..7b722e89 100644 --- a/.check-transitive-deps.json +++ b/.check-transitive-deps.json @@ -1,3 +1,9 @@ { - "deps": ["@mongodb-js/*", "mongodb", "mongodb-*"] + "deps": [ + "@mongodb-js/*", + "mongodb", + "mongodb-*", + "@mongosh/*", + "bson" + ] } From 2e0c1dd767236898bccf2992d03bb8ef2857025a Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 15 Apr 2026 07:31:38 +0100 Subject: [PATCH 13/15] eslint --- packages/monorepo-tools/src/check-transitive-deps.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/check-transitive-deps.spec.ts index 32ce2ae6..6a385eaf 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.spec.ts @@ -119,9 +119,9 @@ describe('check-transitive-deps', function () { const groups = await gatherTransitiveDepsInfo({ ...baseOpts, packages, - resolveExternal: async (name, range) => { + resolveExternal: async (name) => { if (name === 'tracked-dep') return trackedDepManifest; - return {}; + return Promise.resolve({}); }, }); From 79234dde330af2e061c60015ce42d68bba0412d6 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 15 Apr 2026 10:08:01 +0100 Subject: [PATCH 14/15] add tests for getPackagesInTopologicalOrder because that's now considered for coverage.. --- .../get-packages-in-topological-order.spec.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 packages/monorepo-tools/src/utils/get-packages-in-topological-order.spec.ts diff --git a/packages/monorepo-tools/src/utils/get-packages-in-topological-order.spec.ts b/packages/monorepo-tools/src/utils/get-packages-in-topological-order.spec.ts new file mode 100644 index 00000000..4338dd40 --- /dev/null +++ b/packages/monorepo-tools/src/utils/get-packages-in-topological-order.spec.ts @@ -0,0 +1,213 @@ +import assert from 'assert'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { getPackagesInTopologicalOrder } from './get-packages-in-topological-order'; + +async function createMonorepo( + root: string, + workspaces: string[], + packages: Record>, +) { + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ workspaces }), + ); + + for (const [dir, packageJson] of Object.entries(packages)) { + const pkgDir = path.join(root, dir); + await fs.mkdir(pkgDir, { recursive: true }); + await fs.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify(packageJson), + ); + } +} + +describe('getPackagesInTopologicalOrder', function () { + let tmpDir: string; + + beforeEach(async function () { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'topo-test-')); + }); + + afterEach(async function () { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns a single package', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + + assert.deepStrictEqual( + result.map((p) => p.name), + ['pkg-a'], + ); + assert.strictEqual(result[0].version, '1.0.0'); + assert.strictEqual(result[0].private, false); + assert.ok(result[0].location.endsWith(path.join('packages', 'pkg-a'))); + }); + + it('sorts packages in topological order based on dependencies', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + dependencies: { 'pkg-b': '^1.0.0' }, + }, + 'packages/pkg-b': { + name: 'pkg-b', + version: '1.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const names = result.map((p) => p.name); + + assert.ok( + names.indexOf('pkg-b') < names.indexOf('pkg-a'), + `expected pkg-b before pkg-a, got: ${names.join(', ')}`, + ); + }); + + it('handles devDependencies for ordering', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '2.0.0', + devDependencies: { 'pkg-b': '^1.0.0' }, + }, + 'packages/pkg-b': { + name: 'pkg-b', + version: '1.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const names = result.map((p) => p.name); + + assert.ok( + names.indexOf('pkg-b') < names.indexOf('pkg-a'), + `expected pkg-b before pkg-a, got: ${names.join(', ')}`, + ); + }); + + it('handles a chain of dependencies', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + dependencies: { 'pkg-b': '^1.0.0' }, + }, + 'packages/pkg-b': { + name: 'pkg-b', + version: '1.0.0', + dependencies: { 'pkg-c': '^1.0.0' }, + }, + 'packages/pkg-c': { + name: 'pkg-c', + version: '1.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const names = result.map((p) => p.name); + + assert.ok( + names.indexOf('pkg-c') < names.indexOf('pkg-b'), + `expected pkg-c before pkg-b, got: ${names.join(', ')}`, + ); + assert.ok( + names.indexOf('pkg-b') < names.indexOf('pkg-a'), + `expected pkg-b before pkg-a, got: ${names.join(', ')}`, + ); + }); + + it('ignores external dependencies when sorting', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + dependencies: { lodash: '^4.0.0', 'pkg-b': '^1.0.0' }, + }, + 'packages/pkg-b': { + name: 'pkg-b', + version: '1.0.0', + dependencies: { lodash: '^4.0.0' }, + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const names = result.map((p) => p.name); + + assert.strictEqual(names.length, 2); + assert.ok( + names.indexOf('pkg-b') < names.indexOf('pkg-a'), + `expected pkg-b before pkg-a, got: ${names.join(', ')}`, + ); + }); + + it('returns the private field correctly', async function () { + await createMonorepo(tmpDir, ['packages/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + private: true, + }, + 'packages/pkg-b': { + name: 'pkg-b', + version: '2.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const pkgA = result.find((p) => p.name === 'pkg-a'); + const pkgB = result.find((p) => p.name === 'pkg-b'); + + assert.strictEqual(pkgA?.private, true); + assert.strictEqual(pkgB?.private, false); + }); + + it('supports multiple workspace patterns', async function () { + await createMonorepo(tmpDir, ['packages/*', 'configs/*'], { + 'packages/pkg-a': { + name: 'pkg-a', + version: '1.0.0', + dependencies: { 'config-a': '^1.0.0' }, + }, + 'configs/config-a': { + name: 'config-a', + version: '1.0.0', + }, + }); + + const result = await getPackagesInTopologicalOrder(tmpDir); + const names = result.map((p) => p.name); + + assert.strictEqual(names.length, 2); + assert.ok(names.includes('pkg-a')); + assert.ok(names.includes('config-a')); + assert.ok( + names.indexOf('config-a') < names.indexOf('pkg-a'), + `expected config-a before pkg-a, got: ${names.join(', ')}`, + ); + }); + + it('returns empty array when there are no workspaces', async function () { + await fs.writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root' }), + ); + + const result = await getPackagesInTopologicalOrder(tmpDir); + + assert.deepStrictEqual(result, []); + }); +}); From 5e54700f0ea36e2cf10b71f51939b736533768f0 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 15 Apr 2026 10:29:35 +0100 Subject: [PATCH 15/15] move the helpers and tests so that we don't have to import the script itself --- .../src/check-transitive-deps.ts | 241 ++---------------- packages/monorepo-tools/src/index.ts | 1 + .../package-helpers.spec.ts} | 4 +- .../src/utils/package-helpers.ts | 217 ++++++++++++++++ 4 files changed, 235 insertions(+), 228 deletions(-) rename packages/monorepo-tools/src/{check-transitive-deps.spec.ts => utils/package-helpers.spec.ts} (99%) create mode 100644 packages/monorepo-tools/src/utils/package-helpers.ts diff --git a/packages/monorepo-tools/src/check-transitive-deps.ts b/packages/monorepo-tools/src/check-transitive-deps.ts index 97b87735..8d0125d5 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.ts +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -5,18 +5,13 @@ import path from 'path'; import { promises as fs } from 'fs'; import chalk from 'chalk'; import pacote from 'pacote'; -import semver from 'semver'; import { listAllPackages } from './utils/list-all-packages'; -import { getHighestRange } from './utils/semver-helpers'; import minimist from 'minimist'; import type { ParsedArgs } from 'minimist'; - -const DEPENDENCY_GROUPS = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', -] as const; +import { + gatherTransitiveDepsInfo, + findMisalignments, +} from './utils/package-helpers'; const USAGE = `Check transitive dependencies for version alignment. @@ -74,210 +69,6 @@ async function loadConfig(args: ParsedArgs): Promise { return { deps }; } -export function satisfiesHighest( - range: string, - highestVersion: string | null, -): boolean | null { - if (!highestVersion) return null; - if (!semver.validRange(range)) return null; - try { - const minVer = semver.minVersion(highestVersion); - if (!minVer) return null; - return semver.satisfies(minVer, range); - } catch { - return null; - } -} - -export function matchesAnyPattern(name: string, patterns: string[]): boolean { - return patterns.some((pattern) => { - const regex = new RegExp( - '^' + - pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + - '$', - ); - return regex.test(name); - }); -} - -export interface TransitiveDepsEntry { - version: string; - label: string; -} - -// Returns a map from transitive dep name → all usages found across our packages -// and tracked external dependencies. Includes deps with only one unique version; -// callers decide whether to filter those out. -export async function gatherTransitiveDepsInfo({ - deps, - ignoreDevDeps, - packages, - resolveExternal, -}: { - deps: string[]; - ignoreDevDeps: boolean; - packages: - | AsyncIterable<{ packageJson: Record }> - | Iterable<{ packageJson: Record }>; - resolveExternal: ( - name: string, - versionRange: string, - ) => Promise>; -}): Promise> { - const config = { deps }; - // transitiveDep → entries from our own packages that depend on it directly - const ourDirectUsage = new Map(); - - // external dep name → set of version ranges used across our packages - const trackedDepRanges = new Map>(); - - // local package names → their package.json (to avoid npm lookups) - const localPackages = new Map>(); - - for await (const { packageJson } of packages) { - const packageName: string = packageJson.name; - localPackages.set(packageName, packageJson); - const pkgDeps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); - - for (const [depName, version] of pkgDeps) { - if (matchesAnyPattern(depName, config.deps)) { - let entry = ourDirectUsage.get(depName); - if (!entry) { - entry = []; - ourDirectUsage.set(depName, entry); - } - entry.push({ version, label: packageName }); - } - - if (matchesAnyPattern(depName, config.deps)) { - let ranges = trackedDepRanges.get(depName); - if (!ranges) { - ranges = new Set(); - trackedDepRanges.set(depName, ranges); - } - ranges.add(version); - } - } - } - - // Packages that live in the monorepo are not external deps. - for (const localPkgName of localPackages.keys()) { - trackedDepRanges.delete(localPkgName); - } - - // Start result with direct usages, then append indirect ones below. - const result = new Map(); - for (const [depName, entries] of ourDirectUsage) { - result.set(depName, [...entries]); - } - - for (const [trackedDepName, versionRanges] of trackedDepRanges) { - const manifests: Array<{ - label: string; - packageJson: Record; - }> = []; - - const localPkg = localPackages.get(trackedDepName); - if (localPkg) { - manifests.push({ label: trackedDepName, packageJson: localPkg }); - } else { - for (const versionRange of versionRanges) { - try { - const packageJson = await resolveExternal( - trackedDepName, - versionRange, - ); - manifests.push({ - label: `${trackedDepName}@${versionRange}`, - packageJson, - }); - } catch (e: any) { - console.error( - `Warning: could not resolve ${trackedDepName}@${versionRange}: ${e.message as string}`, - ); - } - } - } - - for (const { label, packageJson } of manifests) { - const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); - for (const [depName, version] of deps) { - if (matchesAnyPattern(depName, config.deps)) { - let entry = result.get(depName); - if (!entry) { - entry = []; - result.set(depName, entry); - } - entry.push({ version, label: `via ${label}` }); - } - } - } - } - - return result; -} - -export function getDepsFromPackageJson( - packageJson: Record, - { ignoreDevDeps = false }: { ignoreDevDeps?: boolean } = {}, -): Map { - const deps = new Map(); - for (const group of DEPENDENCY_GROUPS) { - if (ignoreDevDeps && group === 'devDependencies') { - continue; - } - for (const [name, version] of Object.entries( - (packageJson[group] || {}) as Record, - )) { - if (!deps.has(name)) { - deps.set(name, version); - } - } - } - return deps; -} - -export interface MismatchEntry { - version: string; - label: string; - satisfiesHighest: boolean | null; -} - -export interface Mismatch { - name: string; - highestVersion: string | null; - entries: MismatchEntry[]; -} - -export function findMisalignments( - groups: Map, -): Mismatch[] { - const mismatches: Mismatch[] = []; - - for (const name of [...groups.keys()].sort()) { - const entries = groups.get(name)!; - const uniqueVersions = new Set(entries.map((e) => e.version)); - if (uniqueVersions.size <= 1) { - continue; - } - - const allVersions = entries.map((e) => e.version); - const highestVersion = getHighestRange(allVersions); - - mismatches.push({ - name, - highestVersion, - entries: entries.map((e) => ({ - version: e.version, - label: e.label, - satisfiesHighest: satisfiesHighest(e.version, highestVersion), - })), - }); - } - - return mismatches; -} - async function main(args: ParsedArgs) { if (args.help) { console.log(USAGE); @@ -364,16 +155,14 @@ async function main(args: ParsedArgs) { } } -if (process.argv[1]?.endsWith('check-transitive-deps.js')) { - process.on('unhandledRejection', (err: Error) => { - console.error(); - console.error(err?.stack || err?.message || err); - process.exitCode = 1; - }); - - main(minimist(process.argv.slice(2))).catch((err) => - process.nextTick(() => { - throw err; - }), - ); -} +process.on('unhandledRejection', (err: Error) => { + console.error(); + console.error(err?.stack || err?.message || err); + process.exitCode = 1; +}); + +main(minimist(process.argv.slice(2))).catch((err) => + process.nextTick(() => { + throw err; + }), +); diff --git a/packages/monorepo-tools/src/index.ts b/packages/monorepo-tools/src/index.ts index 64f8a2d3..315dbcf1 100644 --- a/packages/monorepo-tools/src/index.ts +++ b/packages/monorepo-tools/src/index.ts @@ -8,3 +8,4 @@ export * from './utils/with-progress'; export * from './utils/workspace-dependencies'; export * from './utils/get-packages-in-topological-order'; export * from './utils/get-npm-token-list'; +export * from './utils/package-helpers'; diff --git a/packages/monorepo-tools/src/check-transitive-deps.spec.ts b/packages/monorepo-tools/src/utils/package-helpers.spec.ts similarity index 99% rename from packages/monorepo-tools/src/check-transitive-deps.spec.ts rename to packages/monorepo-tools/src/utils/package-helpers.spec.ts index 6a385eaf..5fa1573c 100644 --- a/packages/monorepo-tools/src/check-transitive-deps.spec.ts +++ b/packages/monorepo-tools/src/utils/package-helpers.spec.ts @@ -5,9 +5,9 @@ import { getDepsFromPackageJson, gatherTransitiveDepsInfo, findMisalignments, -} from './check-transitive-deps'; +} from './package-helpers'; -describe('check-transitive-deps', function () { +describe('package-helpers', function () { describe('matchesAnyPattern', function () { it('matches an exact package name', function () { assert.equal(matchesAnyPattern('foo', ['foo']), true); diff --git a/packages/monorepo-tools/src/utils/package-helpers.ts b/packages/monorepo-tools/src/utils/package-helpers.ts new file mode 100644 index 00000000..df2094ab --- /dev/null +++ b/packages/monorepo-tools/src/utils/package-helpers.ts @@ -0,0 +1,217 @@ +import semver from 'semver'; +import { getHighestRange } from './semver-helpers'; + +const DEPENDENCY_GROUPS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +export function satisfiesHighest( + range: string, + highestVersion: string | null, +): boolean | null { + if (!highestVersion) return null; + if (!semver.validRange(range)) return null; + try { + const minVer = semver.minVersion(highestVersion); + if (!minVer) return null; + return semver.satisfies(minVer, range); + } catch { + return null; + } +} + +export function matchesAnyPattern(name: string, patterns: string[]): boolean { + return patterns.some((pattern) => { + const regex = new RegExp( + '^' + + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + + '$', + ); + return regex.test(name); + }); +} + +export interface TransitiveDepsEntry { + version: string; + label: string; +} + +// Returns a map from transitive dep name → all usages found across our packages +// and tracked external dependencies. Includes deps with only one unique version; +// callers decide whether to filter those out. +export async function gatherTransitiveDepsInfo({ + deps, + ignoreDevDeps, + packages, + resolveExternal, +}: { + deps: string[]; + ignoreDevDeps: boolean; + packages: + | AsyncIterable<{ packageJson: Record }> + | Iterable<{ packageJson: Record }>; + resolveExternal: ( + name: string, + versionRange: string, + ) => Promise>; +}): Promise> { + const config = { deps }; + // transitiveDep → entries from our own packages that depend on it directly + const ourDirectUsage = new Map(); + + // external dep name → set of version ranges used across our packages + const trackedDepRanges = new Map>(); + + // local package names → their package.json (to avoid npm lookups) + const localPackages = new Map>(); + + for await (const { packageJson } of packages) { + const packageName: string = packageJson.name; + localPackages.set(packageName, packageJson); + const pkgDeps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + + for (const [depName, version] of pkgDeps) { + if (matchesAnyPattern(depName, config.deps)) { + let entry = ourDirectUsage.get(depName); + if (!entry) { + entry = []; + ourDirectUsage.set(depName, entry); + } + entry.push({ version, label: packageName }); + } + + if (matchesAnyPattern(depName, config.deps)) { + let ranges = trackedDepRanges.get(depName); + if (!ranges) { + ranges = new Set(); + trackedDepRanges.set(depName, ranges); + } + ranges.add(version); + } + } + } + + // Packages that live in the monorepo are not external deps. + for (const localPkgName of localPackages.keys()) { + trackedDepRanges.delete(localPkgName); + } + + // Start result with direct usages, then append indirect ones below. + const result = new Map(); + for (const [depName, entries] of ourDirectUsage) { + result.set(depName, [...entries]); + } + + for (const [trackedDepName, versionRanges] of trackedDepRanges) { + const manifests: Array<{ + label: string; + packageJson: Record; + }> = []; + + const localPkg = localPackages.get(trackedDepName); + if (localPkg) { + manifests.push({ label: trackedDepName, packageJson: localPkg }); + } else { + for (const versionRange of versionRanges) { + try { + const packageJson = await resolveExternal( + trackedDepName, + versionRange, + ); + manifests.push({ + label: `${trackedDepName}@${versionRange}`, + packageJson, + }); + } catch (e: any) { + // eslint-disable-next-line no-console + console.error( + `Warning: could not resolve ${trackedDepName}@${versionRange}: ${e.message as string}`, + ); + } + } + } + + for (const { label, packageJson } of manifests) { + const deps = getDepsFromPackageJson(packageJson, { ignoreDevDeps }); + for (const [depName, version] of deps) { + if (matchesAnyPattern(depName, config.deps)) { + let entry = result.get(depName); + if (!entry) { + entry = []; + result.set(depName, entry); + } + entry.push({ version, label: `via ${label}` }); + } + } + } + } + + return result; +} + +export function getDepsFromPackageJson( + packageJson: Record, + { ignoreDevDeps = false }: { ignoreDevDeps?: boolean } = {}, +): Map { + const deps = new Map(); + for (const group of DEPENDENCY_GROUPS) { + if (ignoreDevDeps && group === 'devDependencies') { + continue; + } + for (const [name, version] of Object.entries( + (packageJson[group] || {}) as Record, + )) { + if (!deps.has(name)) { + deps.set(name, version); + } + } + } + return deps; +} + +export interface MismatchEntry { + version: string; + label: string; + satisfiesHighest: boolean | null; +} + +export interface Mismatch { + name: string; + highestVersion: string | null; + entries: MismatchEntry[]; +} + +export function findMisalignments( + groups: Map, +): Mismatch[] { + const mismatches: Mismatch[] = []; + + for (const name of [...groups.keys()].sort()) { + const entries = groups.get(name); + if (!entries) { + continue; + } + const uniqueVersions = new Set(entries.map((e) => e.version)); + if (uniqueVersions.size <= 1) { + continue; + } + + const allVersions = entries.map((e) => e.version); + const highestVersion = getHighestRange(allVersions); + + mismatches.push({ + name, + highestVersion, + entries: entries.map((e) => ({ + version: e.version, + label: e.label, + satisfiesHighest: satisfiesHighest(e.version, highestVersion), + })), + }); + } + + return mismatches; +}