diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index 06831fa..d79d743 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -22,11 +22,12 @@ export async function runCoreJsAnalysis( const messages: ReportPluginResult['messages'] = []; const pkg = context.packageFile; + const productionOnly = context.options?.production ?? false; const hasCoreJs = 'core-js' in (pkg.dependencies ?? {}) || - 'core-js' in (pkg.devDependencies ?? {}) || + (!productionOnly && 'core-js' in (pkg.devDependencies ?? {})) || 'core-js-pure' in (pkg.dependencies ?? {}) || - 'core-js-pure' in (pkg.devDependencies ?? {}); + (!productionOnly && 'core-js-pure' in (pkg.devDependencies ?? {})); if (!hasCoreJs) { return {messages}; diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index 7a90b70..96ae4bb 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -17,7 +17,9 @@ export async function runDependencyAnalysis( const installSize = await context.fs.getInstallSize(); const prodDependencies = Object.keys(pkg.dependencies || {}).length; - const devDependencies = Object.keys(pkg.devDependencies || {}).length; + const devDependencies = context.options?.production + ? 0 + : Object.keys(pkg.devDependencies || {}).length; const stats: Partial = { name: pkg.name, diff --git a/src/analyze/duplicate-dependencies.ts b/src/analyze/duplicate-dependencies.ts index c420ea2..7cc3749 100644 --- a/src/analyze/duplicate-dependencies.ts +++ b/src/analyze/duplicate-dependencies.ts @@ -1,5 +1,5 @@ import {styleText} from 'node:util'; -import {ParsedLockFile, traverse, VisitorFn} from 'lockparse'; +import {ParsedDependency, ParsedLockFile, traverse, VisitorFn} from 'lockparse'; import {AnalysisContext, Message, ReportPluginResult, Stats} from '../types.js'; interface Version { @@ -20,21 +20,53 @@ export async function runDuplicateDependencyAnalysis( throw new Error('No lock file found.'); } - const duplicateDependencies = resolveDuplicateDependencies(lockfile); - await computeParents(lockfile, duplicateDependencies); + const productionOnly = context.options?.production ?? false; + const productionReachable = productionOnly + ? collectProductionReachable([lockfile.root]) + : undefined; + const duplicateDependencies = resolveDuplicateDependencies( + lockfile, + productionReachable + ); + await computeParents(lockfile, duplicateDependencies, productionReachable); return exportOutput(duplicateDependencies); } +/** + * BFS over production+optional deps only; returns the Set of reachable + * ParsedDependency object references (identity-based, not name@version strings). + */ +function collectProductionReachable( + roots: ParsedDependency[] +): Set { + const visited = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const dep = queue.shift(); + if (!dep || visited.has(dep)) { + continue; + } + visited.add(dep); + queue.push(...dep.dependencies, ...dep.optionalDependencies); + } + return visited; +} + /** * Computes a map of package names to their unique versions using the lock file * It returns just the packages with multiple versions * @param lockfile + * @param filter when provided, only packages present in this set (by reference) are considered */ function resolveDuplicateDependencies( - lockfile: ParsedLockFile + lockfile: ParsedLockFile, + filter?: Set ): Map { const resolvedDependencies: Map = new Map(); for (const pkg of lockfile.packages) { + if (filter && !filter.has(pkg)) { + continue; + } const entry: Version = { version: pkg.version, parents: [] @@ -70,12 +102,16 @@ function resolveDuplicateDependencies( */ async function computeParents( lockfile: ParsedLockFile, - duplicateDependencies: Map + duplicateDependencies: Map, + productionReachable: Set | undefined ) { const visitorFn: VisitorFn = (node, parent, _path) => { if (!duplicateDependencies.has(node.name) || !parent) { return; } + if (productionReachable && !productionReachable.has(parent)) { + return; + } const resolvedVersions = duplicateDependencies.get(node.name); if (!resolvedVersions) { return; @@ -96,7 +132,7 @@ async function computeParents( }; const visitor = { dependency: visitorFn, - devDependency: visitorFn, + ...(productionReachable ? {} : {devDependency: visitorFn}), optionalDependency: visitorFn }; diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 6881afb..328019e 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -45,6 +45,12 @@ export const meta = { multiple: true, description: 'Glob pattern(s) for source files to scan for imports (e.g. "src/**/*.ts"). Defaults to scanning all JS/TS files from the project root.' + }, + production: { + type: 'boolean', + default: false, + description: + 'Only analyze production dependencies, ignoring devDependencies' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 09ad0f5..4b52baa 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -102,12 +102,14 @@ export async function run(ctx: CommandContext) { const customManifests = ctx.values['manifest']; const srcDirs = ctx.values['src']; + const production = ctx.values['production']; const {stats, messages} = await report({ root, manifest: customManifests, src: srcDirs, - categories: parsedCategories + categories: parsedCategories, + production }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index c01b994..68b5c4f 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -315,6 +315,39 @@ describe('runCoreJsAnalysis', () => { expect(result.messages[0]?.message).toContain('src'); }); + it('skips core-js in devDependencies when production flag is set', async () => { + await fs.writeFile(path.join(tempDir, 'index.js'), `import 'core-js';\n`); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + devDependencies: {'core-js': '^3.0.0'} + }, + options: {production: true} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('still detects core-js in devDependencies when production flag is not set', async () => { + await fs.writeFile(path.join(tempDir, 'index.js'), `import 'core-js';\n`); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + devDependencies: {'core-js': '^3.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + }); + it('scans multiple src globs when options.src has more than one entry', async () => { for (const dir of ['src', 'app']) { await fs.mkdir(path.join(tempDir, dir), {recursive: true}); diff --git a/src/test/analyze/dependencies.test.ts b/src/test/analyze/dependencies.test.ts index 3616734..c24a4a1 100644 --- a/src/test/analyze/dependencies.test.ts +++ b/src/test/analyze/dependencies.test.ts @@ -200,6 +200,30 @@ describe('analyzeDependencies (local)', () => { expect(result.stats?.dependencyCount?.production).toBe(1); }); + it('should exclude dev dependencies when production flag is set', async () => { + const rootPackage: TestPackage = { + name: 'test-package', + version: '1.0.0', + dependencies: {'prod-package': '1.0.0'}, + devDependencies: {'dev-package': '1.0.0'} + }; + + const dependencies: TestPackage[] = [ + {name: 'prod-package', version: '1.0.0', type: 'commonjs'}, + {name: 'dev-package', version: '1.0.0', type: 'commonjs'} + ]; + + context.packageFile.dependencies = {'prod-package': '1.0.0'}; + context.packageFile.devDependencies = {'dev-package': '1.0.0'}; + context.options = {production: true}; + + await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); + + const result = await runDependencyAnalysis(context); + expect(result.stats?.dependencyCount?.production).toBe(1); + expect(result.stats?.dependencyCount?.development).toBe(0); + }); + it('should handle missing node_modules', async () => { //update package json on context context.packageFile.dependencies = { diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index 37c6a7d..7d317a6 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -3,7 +3,7 @@ import {LocalFileSystem} from '../local-file-system.js'; import {createTempDir, cleanupTempDir} from './utils.js'; import type {AnalysisContext} from '../types.js'; import {runDuplicateDependencyAnalysis} from '../analyze/duplicate-dependencies.js'; -import {ParsedDependency} from 'lockparse'; +import {ParsedDependency, parse as parseLockfile} from 'lockparse'; describe('Duplicate Dependency Detection', () => { let tempDir: string; @@ -112,6 +112,144 @@ describe('Duplicate Dependency Detection', () => { expect(stats).toMatchSnapshot(); }); + it('should exclude dev dependency parents when production flag is set', async () => { + const sharedLibv1: ParsedDependency = { + name: 'shared-lib', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const sharedLibv2: ParsedDependency = { + name: 'shared-lib', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const packageA: ParsedDependency = { + name: 'package-a', + version: '1.0.0', + dependencies: [sharedLibv1], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const devPkg: ParsedDependency = { + name: 'dev-only-pkg', + version: '1.0.0', + dependencies: [sharedLibv2], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const testPkg: ParsedDependency = { + name: 'test-package', + version: '1.0.0', + dependencies: [packageA], + devDependencies: [devPkg], + optionalDependencies: [], + peerDependencies: [] + }; + + context = { + fs: fileSystem, + root: '.', + messages: [], + stats: { + name: 'unknown', + version: 'unknown', + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + options: {production: true}, + lockfile: { + type: 'npm', + packages: [testPkg, packageA, devPkg, sharedLibv1, sharedLibv2], + root: { + name: 'root-package', + version: '1.0.0', + dependencies: [testPkg], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile: { + name: 'test-package', + version: '1.0.0' + } + }; + + const result = await runDuplicateDependencyAnalysis(context); + // shared-lib@2.0.0 is only reachable via dev deps, so with --production + // only shared-lib@1.0.0 is seen and no duplicate is reported at all + expect(result.messages).toHaveLength(0); + }); + + it('should exclude dev-only duplicates when production flag is set (real lockfile)', async () => { + const lockfileContent = JSON.stringify({ + name: 'root-package', + version: '1.0.0', + lockfileVersion: 3, + packages: { + '': { + name: 'root-package', + version: '1.0.0', + dependencies: {'package-a': '^1.0.0'}, + devDependencies: {'dev-only-pkg': '^1.0.0'} + }, + 'node_modules/package-a': { + version: '1.0.0', + dependencies: {'shared-lib': '^1.0.0'} + }, + 'node_modules/package-a/node_modules/shared-lib': { + version: '1.0.0' + }, + 'node_modules/dev-only-pkg': { + version: '1.0.0', + dependencies: {'shared-lib': '^2.0.0'} + }, + 'node_modules/dev-only-pkg/node_modules/shared-lib': { + version: '2.0.0' + } + } + }); + + const lockfile = await parseLockfile( + lockfileContent, + 'package-lock.json', + {name: 'root-package', version: '1.0.0'} + ); + + context = { + fs: fileSystem, + root: '.', + messages: [], + stats: { + name: 'unknown', + version: 'unknown', + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + options: {production: true}, + lockfile, + packageFile: { + name: 'root-package', + version: '1.0.0' + } + }; + + const result = await runDuplicateDependencyAnalysis(context); + // shared-lib@2.0.0 is only reachable via the dev-only dependency, so + // with --production only shared-lib@1.0.0 is seen and no duplicate + // is reported. This pins the contract between resolveDuplicateDependencies' + // identity-based filtering and lockparse's actual object references. + expect(result.messages).toHaveLength(0); + }); + it('should not detect duplicates when there are none', async () => { const sharedLibv1: ParsedDependency = { name: 'shared-lib', diff --git a/src/types.ts b/src/types.ts index a39af0c..753c96c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ export interface Options { manifest?: string[]; src?: string[]; categories?: ParsedCategories; + production?: boolean; } export interface StatLike {