diff --git a/.check-transitive-deps.json b/.check-transitive-deps.json new file mode 100644 index 00000000..7b722e89 --- /dev/null +++ b/.check-transitive-deps.json @@ -0,0 +1,9 @@ +{ + "deps": [ + "@mongodb-js/*", + "mongodb", + "mongodb-*", + "@mongosh/*", + "bson" + ] +} 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..8d0125d5 --- /dev/null +++ b/packages/monorepo-tools/src/check-transitive-deps.ts @@ -0,0 +1,168 @@ +#! /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'; +import { + gatherTransitiveDepsInfo, + findMisalignments, +} from './utils/package-helpers'; + +const USAGE = `Check transitive dependencies for version alignment. + +USAGE: check-transitive-deps.js [--deps ] [--config ] [--ignore-dev-deps] + +Options: + + --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/*"] + } + +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[]; +} + +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 || []; + + return { deps }; +} + +async function main(args: ParsedArgs) { + if (args.help) { + console.log(USAGE); + return; + } + + const config = await loadConfig(args); + + if (config.deps.length === 0) { + console.error('--deps (or deps in config) must be provided and non-empty.'); + process.exitCode = 1; + return; + } + + const ignoreDevDeps: boolean = args['ignore-dev-deps'] === true; + + const groups = await gatherTransitiveDepsInfo({ + ...config, + ignoreDevDeps, + packages: listAllPackages(), + resolveExternal: (name, versionRange) => + pacote.manifest(`${name}@${versionRange}`), + }); + + if (groups.size === 0) { + console.log( + '%s', + chalk.green( + 'No transitive dependencies found matching the provided allow lists.', + ), + ); + return; + } + + const mismatches = findMisalignments(groups); + + if (mismatches.length === 0) { + console.log( + '%s', + chalk.green( + 'All transitive dependencies are aligned, nothing to report!', + ), + ); + return; + } + + for (const { name, highestVersion, entries } of mismatches) { + const versionPad = Math.max(...entries.map((e) => e.version.length)); + + console.log( + '%s %s', + chalk.bold(name), + chalk.dim(`highest: ${highestVersion ?? 'unknown'}`), + ); + console.log(); + + for (const { version, label, satisfiesHighest: match } of entries) { + const indicator = + match === null ? ' ' : match ? chalk.green('✓') : chalk.red('✗'); + console.log( + '%s %s%s %s', + indicator, + ' '.repeat(versionPad - version.length), + version, + chalk.dim(label), + ); + } + + console.log(); + } + + 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) { + console.log(' %s', dep); + } + console.log(); + process.exitCode = 1; + } +} + +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/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, []); + }); +}); diff --git a/packages/monorepo-tools/src/utils/package-helpers.spec.ts b/packages/monorepo-tools/src/utils/package-helpers.spec.ts new file mode 100644 index 00000000..5fa1573c --- /dev/null +++ b/packages/monorepo-tools/src/utils/package-helpers.spec.ts @@ -0,0 +1,377 @@ +import assert from 'assert'; +import { + matchesAnyPattern, + satisfiesHighest, + getDepsFromPackageJson, + gatherTransitiveDepsInfo, + findMisalignments, +} from './package-helpers'; + +describe('package-helpers', 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('gatherTransitiveDepsInfo', function () { + const baseOpts = { + deps: ['tracked-dep', 'shared-lib'], + ignoreDevDeps: false, + }; + + // 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({ + ...baseOpts, + packages, + resolveExternal: async (name) => { + if (name === 'tracked-dep') return trackedDepManifest; + return Promise.resolve({}); + }, + }); + + 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({ + ...baseOpts, + 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: 'shared-lib', // also in the monorepo + }, + }, + { + packageJson: { + name: 'pkg-a', + dependencies: { 'tracked-dep': '^1.0.0' }, + }, + }, + ]; + + let externalCallCount = 0; + const groups = await gatherTransitiveDepsInfo({ + ...baseOpts, + 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({ + ...baseOpts, + packages, + resolveExternal: resolverFor({}), + }); + + assert.equal(groups.size, 0); + }); + }); + + 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' }, + 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/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; +}