diff --git a/package-lock.json b/package-lock.json index d76e627..57a7a2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@publint/pack": "^0.1.4", + "core-js-compat": "^3.48.0", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.3", @@ -2388,6 +2389,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -2418,9 +2431,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -2437,10 +2450,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2463,9 +2477,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001724", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz", - "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==", + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "funding": [ { "type": "opencollective", @@ -2565,6 +2579,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2632,9 +2659,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.171", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz", - "integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==", + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", "license": "ISC" }, "node_modules/es-module-lexer": { @@ -3910,9 +3937,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/obug": { @@ -4745,9 +4772,9 @@ "license": "ISC" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 4e90150..fb5f1ff 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "obug": "^2.1.1", "package-manager-detector": "^1.6.0", "publint": "^0.3.18", + "core-js-compat": "^3.48.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15" }, diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts new file mode 100644 index 0000000..8651915 --- /dev/null +++ b/src/analyze/core-js.ts @@ -0,0 +1,95 @@ +import {glob} from 'tinyglobby'; +import {minVersion} from 'semver'; +import type {AnalysisContext, ReportPluginResult} from '../types.js'; + +import coreJsCompat from 'core-js-compat'; + +const BROAD_IMPORTS = new Set([ + 'core-js', + 'core-js/stable', + 'core-js/actual', + 'core-js/full' +]); + +const SOURCE_GLOB = ['**/*.{js,ts,mjs,cjs,jsx,tsx}']; +const SOURCE_IGNORE = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + '**/lib/**' +]; + +const IMPORT_RE = + /(?:import\s+(?:.*\s+from\s+)?|require\s*\()\s*['"]([^'"]+)['"]/g; + +export async function runCoreJsAnalysis( + context: AnalysisContext +): Promise { + const messages: ReportPluginResult['messages'] = []; + const pkg = context.packageFile; + + const hasCoreJs = + 'core-js' in (pkg.dependencies ?? {}) || + 'core-js' in (pkg.devDependencies ?? {}) || + 'core-js-pure' in (pkg.dependencies ?? {}) || + 'core-js-pure' in (pkg.devDependencies ?? {}); + + if (!hasCoreJs) { + return {messages}; + } + + const nodeRange = pkg.engines?.node; + let targetVersion = 'current'; + if (nodeRange) { + const floor = minVersion(nodeRange); + if (floor) { + targetVersion = floor.version; + } + } + + const {list: unnecessaryForTarget} = coreJsCompat.compat({ + targets: {node: targetVersion}, + inverse: true + }); + const unnecessarySet = new Set(unnecessaryForTarget); + + const srcGlobs = context.options?.src; + const patterns = srcGlobs && srcGlobs.length > 0 ? srcGlobs : SOURCE_GLOB; + const allFiles = await glob(patterns, { + cwd: context.root, + ignore: SOURCE_IGNORE + }); + // filter out any paths that escaped context.root via ../ + const files = allFiles.filter((f) => !f.startsWith('..')); + + for (const filePath of files) { + let source: string; + try { + source = await context.fs.readFile(filePath); + } catch { + continue; + } + + for (const [, specifier] of source.matchAll(IMPORT_RE)) { + if (BROAD_IMPORTS.has(specifier)) { + messages.push({ + severity: 'warning', + score: 0, + message: `Broad core-js import "${specifier}" in ${filePath} loads all polyfills at once. Import only the specific modules you need.` + }); + } else if (specifier.startsWith('core-js/modules/')) { + const moduleName = specifier.slice('core-js/modules/'.length); + if (unnecessarySet.has(moduleName)) { + messages.push({ + severity: 'suggestion', + score: 0, + message: `core-js polyfill "${moduleName}" imported in ${filePath} is unnecessary — your Node.js target (>= ${targetVersion}) already supports this natively.` + }); + } + } + } + } + + return {messages}; +} diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 0e69ceb..01e6f0c 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -16,12 +16,14 @@ import {runPlugins} from '../plugin-runner.js'; import {getPackageJson, detectLockfile} from '../utils/package-json.js'; import {parse as parseLockfile} from 'lockparse'; import {runDuplicateDependencyAnalysis} from './duplicate-dependencies.js'; +import {runCoreJsAnalysis} from './core-js.js'; const plugins: ReportPlugin[] = [ runPublint, runReplacements, runDependencyAnalysis, - runDuplicateDependencyAnalysis + runDuplicateDependencyAnalysis, + runCoreJsAnalysis ]; async function computeInfo(fileSystem: FileSystem) { diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 8e9717a..b662a52 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -25,6 +25,12 @@ export const meta = { type: 'boolean', default: false, description: 'Output results as JSON to stdout' + }, + src: { + type: 'string', + 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.' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 9c9b990..4908c52 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -86,10 +86,12 @@ export async function run(ctx: CommandContext) { } const customManifests = ctx.values['manifest']; + const srcDirs = ctx.values['src']; const {stats, messages} = await report({ root, manifest: customManifests, + src: srcDirs, categories: parsedCategories }); diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts new file mode 100644 index 0000000..799a5ea --- /dev/null +++ b/src/test/analyze/core-js.test.ts @@ -0,0 +1,340 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {createRequire} from 'node:module'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {runCoreJsAnalysis} from '../../analyze/core-js.js'; +import {LocalFileSystem} from '../../local-file-system.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; +import type {AnalysisContext} from '../../types.js'; + +const cjsRequire = createRequire(import.meta.url); +const {compat} = cjsRequire('core-js-compat') as { + compat: (opts: {targets: Record; inverse?: boolean}) => { + list: string[]; + }; +}; + +const unnecessaryForNode18 = compat({ + targets: {node: '18.0.0'}, + inverse: true +}).list; +const unnecessaryModule = unnecessaryForNode18[0]; +if (!unnecessaryModule) + throw new Error('core-js-compat returned empty list for node 18'); + +function makeContext( + tempDir: string, + overrides: Partial = {} +): AnalysisContext { + return { + fs: new LocalFileSystem(tempDir), + root: tempDir, + messages: [], + stats: { + name: 'test-package', + version: '1.0.0', + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + lockfile: { + type: 'npm', + packages: [], + root: { + name: 'test-package', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile: { + name: 'test-package', + version: '1.0.0' + }, + ...overrides + }; +} + +describe('runCoreJsAnalysis', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('skips when core-js is not in dependencies', async () => { + const context = makeContext(tempDir, { + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('skips when only core-js-pure is absent but unrelated deps exist', async () => { + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {lodash: '4.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('warns on broad core-js import', 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', + dependencies: {'core-js': '^3.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + const broadMsg = result.messages[0]; + expect(broadMsg).toBeDefined(); + expect(broadMsg?.severity).toBe('warning'); + expect(broadMsg?.message).toContain('"core-js"'); + }); + + it('warns on all broad import variants', async () => { + await fs.writeFile( + path.join(tempDir, 'index.js'), + [ + `import 'core-js';`, + `import 'core-js/stable';`, + `import 'core-js/actual';`, + `import 'core-js/full';` + ].join('\n') + ); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(4); + expect(result.messages.every((m) => m.severity === 'warning')).toBe(true); + }); + + it('suggests when a specific module is unnecessary for the node target', async () => { + await fs.writeFile( + path.join(tempDir, 'index.js'), + `import 'core-js/modules/${unnecessaryModule}';\n` + ); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'}, + engines: {node: '>=18'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + const suggestionMsg = result.messages[0]; + expect(suggestionMsg).toBeDefined(); + expect(suggestionMsg?.severity).toBe('suggestion'); + expect(suggestionMsg?.message).toContain(unnecessaryModule); + }); + + it('emits no message for a require() broad import', async () => { + await fs.writeFile(path.join(tempDir, 'index.js'), `require('core-js');\n`); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.severity).toBe('warning'); + }); + + it('emits no message for a core-js/modules import that is still needed', async () => { + const necessaryModules = compat({ + targets: {node: '0.10.0'}, + inverse: true + }).list; + const necessaryForOldNode = unnecessaryForNode18.filter( + (m) => !necessaryModules.includes(m) + ); + + if (necessaryForOldNode.length === 0) { + return; + } + + const neededModule = necessaryForOldNode[0]; + expect(neededModule).toBeDefined(); + if (!neededModule) + throw new Error('necessaryForOldNode was unexpectedly empty'); + + await fs.writeFile( + path.join(tempDir, 'index.js'), + `import 'core-js/modules/${neededModule}';\n` + ); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'}, + engines: {node: '>=0.10'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('detects core-js in devDependencies', 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('detects core-js-pure in dependencies', async () => { + await fs.writeFile( + path.join(tempDir, 'index.ts'), + `import 'core-js/modules/${unnecessaryModule}';\n` + ); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js-pure': '^3.0.0'}, + engines: {node: '>=18'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.severity).toBe('suggestion'); + }); + + it('falls back to current node version when engines.node is absent', 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', + dependencies: {'core-js': '^3.0.0'} + } + }); + + await expect(runCoreJsAnalysis(context)).resolves.not.toThrow(); + }); + + it('ignores files in excluded directories', async () => { + for (const dir of ['node_modules', 'dist', 'build', 'coverage', 'lib']) { + await fs.mkdir(path.join(tempDir, dir), {recursive: true}); + await fs.writeFile( + path.join(tempDir, dir, 'index.js'), + `import 'core-js';\n` + ); + } + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'} + } + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('scans only specified src globs when options.src is provided', async () => { + await fs.mkdir(path.join(tempDir, 'src'), {recursive: true}); + await fs.mkdir(path.join(tempDir, 'other'), {recursive: true}); + await fs.writeFile( + path.join(tempDir, 'src', 'index.js'), + `import 'core-js';\n` + ); + // This file is outside src/ and should NOT be scanned + await fs.writeFile( + path.join(tempDir, 'other', 'index.js'), + `import 'core-js';\n` + ); + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'} + }, + options: {src: ['src/**/*.js']} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('src'); + }); + + 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}); + await fs.writeFile( + path.join(tempDir, dir, 'index.js'), + `import 'core-js';\n` + ); + } + + const context = makeContext(tempDir, { + packageFile: { + name: 'test-package', + version: '1.0.0', + dependencies: {'core-js': '^3.0.0'} + }, + options: {src: ['src/**/*.js', 'app/**/*.js']} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(2); + }); +}); diff --git a/src/types.ts b/src/types.ts index a02c116..a39af0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import type {ParsedCategories} from './categories.js'; export interface Options { root?: string; manifest?: string[]; + src?: string[]; categories?: ParsedCategories; }