diff --git a/action.yml b/action.yml index 9c4c9b4..d4ba620 100644 --- a/action.yml +++ b/action.yml @@ -44,6 +44,10 @@ inputs: description: 'Detect modules which have community suggested alternatives' required: false default: true + include-dev-deps: + description: 'Include dev dependencies in the analysis' + required: false + default: 'true' working-directory: description: 'Working directory to scan for package lock file' required: false diff --git a/build/main.js b/build/main.js index 5b36ab9..d543c91 100644 --- a/build/main.js +++ b/build/main.js @@ -24033,11 +24033,23 @@ var supportedLockfiles = [ "yarn.lock", "bun.lock" ]; -function computeDependencyVersions(lockFile) { +function computeDependencyVersions(lockFile, includeDevDeps) { const result = /* @__PURE__ */ new Map(); - for (const pkg of lockFile.packages) { - if (!pkg.name || !pkg.version) continue; - addVersion(result, pkg.name, pkg.version); + if (!includeDevDeps) { + const visitorFn = (node) => { + if (!node.name || !node.version) return; + addVersion(result, node.name, node.version); + }; + traverse(lockFile.root, { + dependency: visitorFn, + optionalDependency: visitorFn, + peerDependency: visitorFn + }); + } else { + for (const pkg of lockFile.packages) { + if (!pkg.name || !pkg.version) continue; + addVersion(result, pkg.name, pkg.version); + } } return result; } @@ -24452,7 +24464,7 @@ function getLsCommand(lockfilePath, packageName) { } return void 0; } -function computeParentPaths(lockfile, duplicateDependencyNames, dependencyMap) { +function computeParentPaths(lockfile, duplicateDependencyNames, dependencyMap, includeDevDeps) { const parentPaths = /* @__PURE__ */ new Map(); const visitorFn = (node, _parent, path2) => { if (!duplicateDependencyNames.has(node.name) || !path2) { @@ -24471,13 +24483,13 @@ function computeParentPaths(lockfile, duplicateDependencyNames, dependencyMap) { }; const visitor = { dependency: visitorFn, - devDependency: visitorFn, + ...includeDevDeps ? { devDependency: visitorFn } : {}, optionalDependency: visitorFn }; traverse(lockfile.root, visitor); return parentPaths; } -function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath, lockfile) { +function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath, lockfile, includeDevDeps) { const duplicateRows = []; const duplicateDependencyNames = /* @__PURE__ */ new Set(); for (const [packageName, currentVersionSet] of dependencyMap) { @@ -24491,7 +24503,8 @@ function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath, loc const parentPaths = computeParentPaths( lockfile, duplicateDependencyNames, - dependencyMap + dependencyMap, + includeDevDeps ); for (const name of duplicateDependencyNames) { const versionSet = dependencyMap.get(name); @@ -24820,6 +24833,7 @@ async function run() { const token = getInput("github-token", { required: true }); const prNumber = parseInt(getInput("pr-number", { required: true }), 10); const detectReplacements = getBooleanInput("detect-replacements"); + const includeDevDeps = getBooleanInput("include-dev-deps"); const dependencyThreshold = parseInt( getInput("dependency-threshold") || "10", 10 @@ -24906,8 +24920,11 @@ async function run() { setFailed(`Failed to parse base lockfile: ${err}`); return; } - const currentDeps = computeDependencyVersions(parsedCurrentLock); - const baseDeps = computeDependencyVersions(parsedBaseLock); + const currentDeps = computeDependencyVersions( + parsedCurrentLock, + includeDevDeps + ); + const baseDeps = computeDependencyVersions(parsedBaseLock, includeDevDeps); info(`Dependency threshold set to ${dependencyThreshold}`); info(`Size threshold set to ${formatBytes(sizeThreshold)}`); info(`Duplicate threshold set to ${duplicateThreshold}`); @@ -24924,7 +24941,8 @@ async function run() { duplicateThreshold, currentDeps, lockfilePath, - parsedCurrentLock + parsedCurrentLock, + includeDevDeps ); await scanForDependencySize( messages, @@ -24964,15 +24982,19 @@ async function run() { ); return; } - const baseDependencies = getDependenciesFromPackageJson(basePackageJson, [ + const depTypes = [ "optional", "peer", - "dev", + ...includeDevDeps ? ["dev"] : [], "prod" - ]); + ]; + const baseDependencies = getDependenciesFromPackageJson( + basePackageJson, + depTypes + ); const currentDependencies = getDependenciesFromPackageJson( currentPackageJson, - ["optional", "peer", "dev", "prod"] + depTypes ); scanForReplacements(messages, baseDependencies, currentDependencies); } diff --git a/src/checks/duplicates.ts b/src/checks/duplicates.ts index 80038a5..a99a4ef 100644 --- a/src/checks/duplicates.ts +++ b/src/checks/duplicates.ts @@ -22,7 +22,8 @@ function getLsCommand( function computeParentPaths( lockfile: ParsedLockFile, duplicateDependencyNames: Set, - dependencyMap: Map> + dependencyMap: Map>, + includeDevDeps: boolean ): Map { const parentPaths = new Map(); @@ -43,7 +44,7 @@ function computeParentPaths( }; const visitor = { dependency: visitorFn, - devDependency: visitorFn, + ...(includeDevDeps ? {devDependency: visitorFn} : {}), optionalDependency: visitorFn }; @@ -57,7 +58,8 @@ export function scanForDuplicates( threshold: number, dependencyMap: Map>, lockfilePath: string, - lockfile: ParsedLockFile + lockfile: ParsedLockFile, + includeDevDeps: boolean ): void { const duplicateRows: string[] = []; const duplicateDependencyNames = new Set(); @@ -75,7 +77,8 @@ export function scanForDuplicates( const parentPaths = computeParentPaths( lockfile, duplicateDependencyNames, - dependencyMap + dependencyMap, + includeDevDeps ); for (const name of duplicateDependencyNames) { diff --git a/src/lockfile.ts b/src/lockfile.ts index 2bc3c3f..c7cb89b 100644 --- a/src/lockfile.ts +++ b/src/lockfile.ts @@ -1,4 +1,4 @@ -import type {ParsedLockFile} from 'lockparse'; +import {type ParsedLockFile, traverse, type VisitorFn} from 'lockparse'; import {existsSync} from 'node:fs'; import {join} from 'node:path'; @@ -12,13 +12,26 @@ export const supportedLockfiles = [ ] as const; export function computeDependencyVersions( - lockFile: ParsedLockFile + lockFile: ParsedLockFile, + includeDevDeps: boolean ): VersionsSet { const result: VersionsSet = new Map(); - for (const pkg of lockFile.packages) { - if (!pkg.name || !pkg.version) continue; - addVersion(result, pkg.name, pkg.version); + if (!includeDevDeps) { + const visitorFn: VisitorFn = (node) => { + if (!node.name || !node.version) return; + addVersion(result, node.name, node.version); + }; + traverse(lockFile.root, { + dependency: visitorFn, + optionalDependency: visitorFn, + peerDependency: visitorFn + }); + } else { + for (const pkg of lockFile.packages) { + if (!pkg.name || !pkg.version) continue; + addVersion(result, pkg.name, pkg.version); + } } return result; diff --git a/src/main.ts b/src/main.ts index fcaa252..09ed9e0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {parse as parseLockfile, type ParsedLockFile} from 'lockparse'; import {detectLockfile, computeDependencyVersions} from './lockfile.js'; import {getFileFromRef, getBaseRef, tryGetJSONFromRef} from './git.js'; -import {getDependenciesFromPackageJson} from './npm.js'; +import {getDependenciesFromPackageJson, type DependencyType} from './npm.js'; import {getPacksFromPattern} from './packs.js'; import {scanForReplacements} from './checks/replacements.js'; import {scanForDuplicates} from './checks/duplicates.js'; @@ -34,6 +34,7 @@ async function run(): Promise { const token = core.getInput('github-token', {required: true}); const prNumber = parseInt(core.getInput('pr-number', {required: true}), 10); const detectReplacements = core.getBooleanInput('detect-replacements'); + const includeDevDeps = core.getBooleanInput('include-dev-deps'); const dependencyThreshold = parseInt( core.getInput('dependency-threshold') || '10', 10 @@ -129,8 +130,11 @@ async function run(): Promise { return; } - const currentDeps = computeDependencyVersions(parsedCurrentLock); - const baseDeps = computeDependencyVersions(parsedBaseLock); + const currentDeps = computeDependencyVersions( + parsedCurrentLock, + includeDevDeps + ); + const baseDeps = computeDependencyVersions(parsedBaseLock, includeDevDeps); core.info(`Dependency threshold set to ${dependencyThreshold}`); core.info(`Size threshold set to ${formatBytes(sizeThreshold)}`); @@ -150,7 +154,8 @@ async function run(): Promise { duplicateThreshold, currentDeps, lockfilePath, - parsedCurrentLock + parsedCurrentLock, + includeDevDeps ); await scanForDependencySize( @@ -198,15 +203,19 @@ async function run(): Promise { return; } - const baseDependencies = getDependenciesFromPackageJson(basePackageJson, [ + const depTypes: DependencyType[] = [ 'optional', 'peer', - 'dev', + ...(includeDevDeps ? (['dev'] as const) : []), 'prod' - ]); + ]; + const baseDependencies = getDependenciesFromPackageJson( + basePackageJson, + depTypes + ); const currentDependencies = getDependenciesFromPackageJson( currentPackageJson, - ['optional', 'peer', 'dev', 'prod'] + depTypes ); scanForReplacements(messages, baseDependencies, currentDependencies); diff --git a/test/checks/duplicates_test.ts b/test/checks/duplicates_test.ts index f133055..5044dbb 100644 --- a/test/checks/duplicates_test.ts +++ b/test/checks/duplicates_test.ts @@ -29,7 +29,8 @@ describe('scanForDuplicates', () => { threshold, dependencyMap, lockfilePath, - lockfile + lockfile, + true ); expect(messages).toHaveLength(0); @@ -85,7 +86,8 @@ describe('scanForDuplicates', () => { threshold, dependencyMap, lockfilePath, - lockfile + lockfile, + true ); expect(messages).toMatchSnapshot(); @@ -141,12 +143,67 @@ describe('scanForDuplicates', () => { threshold, dependencyMap, lockfilePath, - lockfile + lockfile, + true ); expect(messages).toHaveLength(0); }); + it('should exclude dev dependency duplicates when includeDevDeps is false', () => { + const messages: string[] = []; + const threshold = 1; + const packageA: ParsedDependency = { + name: 'package-a', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const packageAAlt: ParsedDependency = { + name: 'package-a', + version: '1.1.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const dependencyMap = new Map>([ + ['package-a', new Set(['1.0.0', '1.1.0'])] + ]); + const lockfilePath = 'package-lock.json'; + const lockfile: ParsedLockFile = { + type: 'npm', + packages: [packageA, packageAAlt], + root: { + name: 'root-package', + version: '1.0.0', + dependencies: [packageA], + devDependencies: [packageAAlt], + optionalDependencies: [], + peerDependencies: [] + } + }; + + scanForDuplicates( + messages, + threshold, + dependencyMap, + lockfilePath, + lockfile, + false + ); + + // With includeDevDeps=false, the devDependency path for packageAAlt should not be traversed + // The duplicate is still reported (since dependencyMap has both versions), + // but parent paths for the dev-only version won't be found + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('package-a'); + // The prod version should have a parent path, the dev version should not + expect(messages[0]).toContain('package-a@1.0.0'); + }); + it('should truncate long parent paths in the report', () => { const messages: string[] = []; const threshold = 1; @@ -204,7 +261,8 @@ describe('scanForDuplicates', () => { threshold, dependencyMap, lockfilePath, - lockfile + lockfile, + true ); expect(messages).toMatchSnapshot(); diff --git a/test/lockfile_test.ts b/test/lockfile_test.ts index 5bdef3b..9d4e496 100644 --- a/test/lockfile_test.ts +++ b/test/lockfile_test.ts @@ -21,7 +21,7 @@ const mockLockFile: ParsedLockFile = { describe('computeDependencyVersions', () => { it('should return an empty map for an empty lock file', () => { const lockFile: ParsedLockFile = {...mockLockFile}; - const result = computeDependencyVersions(lockFile); + const result = computeDependencyVersions(lockFile, true); expect(result.size).toBe(0); }); @@ -55,12 +55,138 @@ describe('computeDependencyVersions', () => { } ] }; - const result = computeDependencyVersions(lockFile); + const result = computeDependencyVersions(lockFile, true); expect(result.size).toBe(2); expect(result.get('foo')).toEqual(new Set(['1.0.0', '1.1.0'])); expect(result.get('bar')).toEqual(new Set(['2.0.0'])); }); + it('should exclude dev dependencies when includeDevDeps is false', () => { + const lockFile: ParsedLockFile = { + ...mockLockFile, + packages: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'bar', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'dev-only', + version: '3.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + root: { + name: 'root', + version: '1.0.0', + dependencies: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + devDependencies: [ + { + name: 'dev-only', + version: '3.0.0', + dependencies: [ + { + name: 'bar', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + optionalDependencies: [], + peerDependencies: [] + } + }; + const result = computeDependencyVersions(lockFile, false); + expect(result.size).toBe(1); + expect(result.get('foo')).toEqual(new Set(['1.0.0'])); + expect(result.has('dev-only')).toBe(false); + expect(result.has('bar')).toBe(false); + }); + + it('should include all packages when includeDevDeps is true', () => { + const lockFile: ParsedLockFile = { + ...mockLockFile, + packages: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }, + { + name: 'dev-only', + version: '3.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + root: { + name: 'root', + version: '1.0.0', + dependencies: [ + { + name: 'foo', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + devDependencies: [ + { + name: 'dev-only', + version: '3.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + ], + optionalDependencies: [], + peerDependencies: [] + } + }; + const result = computeDependencyVersions(lockFile, true); + expect(result.size).toBe(2); + expect(result.get('foo')).toEqual(new Set(['1.0.0'])); + expect(result.get('dev-only')).toEqual(new Set(['3.0.0'])); + }); + it('should ignore packages without name or version', () => { const lockFile: ParsedLockFile = { ...mockLockFile, @@ -91,7 +217,7 @@ describe('computeDependencyVersions', () => { } ] }; - const result = computeDependencyVersions(lockFile); + const result = computeDependencyVersions(lockFile, true); expect(result.size).toBe(1); expect(result.get('foo')).toEqual(new Set(['1.0.0'])); });