diff --git a/package-lock.json b/package-lock.json index 2fdf04c..f42cc75 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", @@ -38,7 +39,7 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", - "vitest": "^4.0.3" + "vitest": "^4.1.0" } }, "node_modules/@actions/core": { @@ -2388,6 +2389,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "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.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "funding": [ { "type": "opencollective", @@ -2565,6 +2579,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "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.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "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 6db8101..db959e7 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" }, @@ -72,6 +73,6 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", - "vitest": "^4.0.3" + "vitest": "^4.1.0" } } diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts new file mode 100644 index 0000000..66e9c58 --- /dev/null +++ b/src/analyze/core-js.ts @@ -0,0 +1,103 @@ +import {join} from 'node:path'; +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 srcDirs = context.options?.src; + let files: string[]; + if (srcDirs && srcDirs.length > 0) { + const results = await Promise.all( + srcDirs.map(async (dir) => { + const matches = await glob(SOURCE_GLOB, { + cwd: join(context.root, dir) + }); + return matches.map((f) => join(dir, f)); + }) + ); + files = results.flat(); + } else { + files = await glob(SOURCE_GLOB, {cwd: context.root, ignore: SOURCE_IGNORE}); + } + + 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 16a033c..35c43ba 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -19,6 +19,12 @@ export const meta = { type: 'boolean', default: false, description: 'Output results as JSON to stdout' + }, + src: { + type: 'string', + multiple: true, + description: + 'Path(s) to source directories to scan for imports (e.g. src, app). Defaults to scanning from the project root.' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 9dc6eeb..a8b7af2 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -72,10 +72,12 @@ export async function run(ctx: CommandContext) { // Then read the manifest const customManifests = ctx.values['manifest']; + const srcDirs = ctx.values['src']; const {stats, messages} = await report({ root, - manifest: customManifests + manifest: customManifests, + src: srcDirs }); 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 new file mode 100644 index 0000000..dfa1a08 --- /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 dirs 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']} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('src'); + }); + + it('scans multiple src dirs 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', 'app']} + }); + + const result = await runCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(2); + }); +}); diff --git a/src/types.ts b/src/types.ts index ac96d5c..7a4b101 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ import type {ParsedLockFile} from 'lockparse'; export interface Options { root?: string; manifest?: string[]; + src?: string[]; } export interface StatLike {