From 92d081e6e6ad7d8c4632deed3211561afae65c48 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Mon, 2 Mar 2026 20:43:36 +0800 Subject: [PATCH 01/11] Add option for JSON output file --- src/commands/analyze.meta.ts | 6 +++++ src/commands/analyze.ts | 34 +++++++++++++++++++++++---- src/test/cli.test.ts | 45 +++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 229b954..003a29c 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -14,6 +14,12 @@ export const meta = { multiple: true, description: 'Path(s) to custom manifest file(s) for module replacements analysis' + }, + json: { + type: 'boolean', + default: false, + description: + 'Write results as JSON to e18e-cli-results.json in the analyzed directory' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index a01e4eb..330f9d9 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,5 +1,6 @@ import {type CommandContext} from 'gunshi'; import {promises as fsp, type Stats} from 'node:fs'; +import {join} from 'node:path'; import * as prompts from '@clack/prompts'; import {styleText} from 'node:util'; import {meta} from './analyze.meta.js'; @@ -20,6 +21,8 @@ function formatBytes(bytes: number) { return `${size.toFixed(1)} ${units[unitIndex]}`; } +const JSON_OUTPUT_FILENAME = 'e18e-cli-results.json'; + const SEVERITY_RANK: Record = { error: 3, warning: 2, @@ -37,7 +40,8 @@ export async function run(ctx: CommandContext) { const providedPath = ctx.positionals.length > 1 ? ctx.positionals[1] : undefined; const logLevel = ctx.values['log-level']; - let root: string | undefined = undefined; + const jsonOutput = ctx.values['json']; + let root: string | undefined; // Enable debug output based on log level if (logLevel === 'debug') { @@ -71,6 +75,30 @@ export async function run(ctx: CommandContext) { manifest: customManifests }); + const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; + const hasFailingMessages = + thresholdRank > 0 && + messages.some((m) => SEVERITY_RANK[m.severity] >= thresholdRank); + + if (jsonOutput) { + const outputPath = join(root ?? process.cwd(), JSON_OUTPUT_FILENAME); + try { + await fsp.writeFile( + outputPath, + JSON.stringify({stats, messages}, null, 2) + '\n' + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + prompts.cancel(`Failed to write output file: ${reason}`); + process.exit(1); + } + prompts.outro(`Output written to ${outputPath}`); + if (hasFailingMessages) { + process.exit(1); + } + return; + } + prompts.log.info('Summary'); const totalDeps = @@ -197,10 +225,6 @@ export async function run(ctx: CommandContext) { prompts.outro('Done!'); // Exit with non-zero when messages meet the fail threshold (--log-level) - const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; - const hasFailingMessages = - thresholdRank > 0 && - messages.some((m) => SEVERITY_RANK[m.severity] >= thresholdRank); if (hasFailingMessages) { process.exit(1); } diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index efaec2c..b935d8d 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeAll, afterAll} from 'vitest'; +import {describe, it, expect, beforeAll, afterAll, afterEach} from 'vitest'; import {spawn} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -157,6 +157,49 @@ describe('analyze exit codes', () => { }); }); +describe('analyze --json', () => { + beforeAll(async () => { + const nodeModules = path.join(basicChalkFixture, 'node_modules'); + if (!existsSync(nodeModules)) { + execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'}); + } + }); + + afterEach(async () => { + await fs.rm(path.join(tempDir, 'e18e-cli-results.json'), {force: true}); + await fs.rm(path.join(basicChalkFixture, 'e18e-cli-results.json'), { + force: true + }); + }); + + it('writes valid JSON to e18e-cli-results.json', async () => { + const {code} = await runCliProcess( + ['analyze', '--json', '--log-level=error'], + tempDir + ); + expect(code).toBe(0); + const outputFile = path.join(tempDir, 'e18e-cli-results.json'); + const parsed = JSON.parse(await fs.readFile(outputFile, 'utf8')); + expect(parsed).toHaveProperty('stats'); + expect(parsed).toHaveProperty('messages'); + expect(parsed.stats).toHaveProperty('name', 'mock-package'); + expect(parsed.stats).toHaveProperty('version', '1.0.0'); + expect(parsed.stats).toHaveProperty('dependencyCount'); + expect(Array.isArray(parsed.messages)).toBe(true); + }); + + it('exits 1 with --json when messages meet fail threshold', async () => { + const {code} = await runCliProcess( + ['analyze', '--json'], + basicChalkFixture + ); + expect(code).toBe(1); + const outputFile = path.join(basicChalkFixture, 'e18e-cli-results.json'); + const parsed = JSON.parse(await fs.readFile(outputFile, 'utf8')); + expect(parsed.messages.length).toBeGreaterThan(0); + }); +}); + describe('analyze fixable summary', () => { it('includes fixable-by-migrate summary when project has fixable replacement', async () => { const {stdout, stderr, code} = await runCliProcess( From 4a326c0ff5eeb44c2b294287a7ea1ffd31c57cc5 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Mon, 2 Mar 2026 21:28:05 +0800 Subject: [PATCH 02/11] Moved back to JSON output --- src/cli.ts | 8 ++++++-- src/commands/analyze.meta.ts | 3 +-- src/commands/analyze.ts | 26 +++++++++----------------- src/test/cli.test.ts | 21 ++++++--------------- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6525c18..b89f3e6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,9 +29,13 @@ const subCommands = new Map>([ ['migrate', lazy(migrateCommand, migrateMeta)] ]); -cli(process.argv.slice(2), defaultCommand, { +const args = process.argv.slice(2); +const isJsonMode = args[0] === 'analyze' && args.includes('--json'); + +cli(args, defaultCommand, { name: 'cli', version, description: `${styleText('cyan', 'e18e')}`, - subCommands + subCommands, + renderHeader: isJsonMode ? null : undefined }); diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 003a29c..16a033c 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -18,8 +18,7 @@ export const meta = { json: { type: 'boolean', default: false, - description: - 'Write results as JSON to e18e-cli-results.json in the analyzed directory' + description: 'Output results as JSON to stdout' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 330f9d9..9dc6eeb 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,6 +1,5 @@ import {type CommandContext} from 'gunshi'; import {promises as fsp, type Stats} from 'node:fs'; -import {join} from 'node:path'; import * as prompts from '@clack/prompts'; import {styleText} from 'node:util'; import {meta} from './analyze.meta.js'; @@ -21,8 +20,6 @@ function formatBytes(bytes: number) { return `${size.toFixed(1)} ${units[unitIndex]}`; } -const JSON_OUTPUT_FILENAME = 'e18e-cli-results.json'; - const SEVERITY_RANK: Record = { error: 3, warning: 2, @@ -48,7 +45,9 @@ export async function run(ctx: CommandContext) { enableDebug('e18e:*'); } - prompts.intro('Analyzing...'); + if (!jsonOutput) { + prompts.intro('Analyzing...'); + } // Path can be a directory (analyze project) if (providedPath) { @@ -60,7 +59,11 @@ export async function run(ctx: CommandContext) { } if (!stat || !stat.isDirectory()) { - prompts.cancel(`Path must be a directory: ${providedPath}`); + if (jsonOutput) { + process.stderr.write(`Path must be a directory: ${providedPath}\n`); + } else { + prompts.cancel(`Path must be a directory: ${providedPath}`); + } process.exit(1); } @@ -81,18 +84,7 @@ export async function run(ctx: CommandContext) { messages.some((m) => SEVERITY_RANK[m.severity] >= thresholdRank); if (jsonOutput) { - const outputPath = join(root ?? process.cwd(), JSON_OUTPUT_FILENAME); - try { - await fsp.writeFile( - outputPath, - JSON.stringify({stats, messages}, null, 2) + '\n' - ); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - prompts.cancel(`Failed to write output file: ${reason}`); - process.exit(1); - } - prompts.outro(`Output written to ${outputPath}`); + process.stdout.write(JSON.stringify({stats, messages}, null, 2) + '\n'); if (hasFailingMessages) { process.exit(1); } diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index b935d8d..91119c3 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeAll, afterAll, afterEach} from 'vitest'; +import {describe, it, expect, beforeAll, afterAll} from 'vitest'; import {spawn} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -165,21 +165,13 @@ describe('analyze --json', () => { } }); - afterEach(async () => { - await fs.rm(path.join(tempDir, 'e18e-cli-results.json'), {force: true}); - await fs.rm(path.join(basicChalkFixture, 'e18e-cli-results.json'), { - force: true - }); - }); - - it('writes valid JSON to e18e-cli-results.json', async () => { - const {code} = await runCliProcess( + it('outputs valid JSON to stdout', async () => { + const {stdout, code} = await runCliProcess( ['analyze', '--json', '--log-level=error'], tempDir ); expect(code).toBe(0); - const outputFile = path.join(tempDir, 'e18e-cli-results.json'); - const parsed = JSON.parse(await fs.readFile(outputFile, 'utf8')); + const parsed = JSON.parse(stdout); expect(parsed).toHaveProperty('stats'); expect(parsed).toHaveProperty('messages'); expect(parsed.stats).toHaveProperty('name', 'mock-package'); @@ -189,13 +181,12 @@ describe('analyze --json', () => { }); it('exits 1 with --json when messages meet fail threshold', async () => { - const {code} = await runCliProcess( + const {stdout, code} = await runCliProcess( ['analyze', '--json'], basicChalkFixture ); expect(code).toBe(1); - const outputFile = path.join(basicChalkFixture, 'e18e-cli-results.json'); - const parsed = JSON.parse(await fs.readFile(outputFile, 'utf8')); + const parsed = JSON.parse(stdout); expect(parsed.messages.length).toBeGreaterThan(0); }); }); From ebcc66876ef9a374b4b98a6385e7cee2bf2362b7 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 09:37:05 +0800 Subject: [PATCH 03/11] Adding Core-JS detection to CLI --- package-lock.json | 65 +++-- package.json | 1 + src/analyze/core-js.ts | 174 ++++++++++++ src/analyze/report.ts | 5 +- src/commands/analyze.meta.ts | 5 + src/commands/analyze.ts | 4 +- src/test/analyze/core-js.test.ts | 441 +++++++++++++++++++++++++++++++ src/types.ts | 1 + 8 files changed, 675 insertions(+), 21 deletions(-) create mode 100644 src/analyze/core-js.ts create mode 100644 src/test/analyze/core-js.test.ts diff --git a/package-lock.json b/package-lock.json index 48c14a5..add0c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@publint/pack": "^0.1.4", + "core-js-compat": "^3.48.0", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.2", @@ -2836,6 +2837,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", @@ -2866,9 +2879,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", @@ -2885,10 +2898,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" @@ -2911,9 +2925,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", @@ -3013,6 +3027,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", @@ -3070,9 +3097,9 @@ "license": "ISC" }, "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": { @@ -4129,9 +4156,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": { @@ -4975,9 +5002,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 0bbb896..443a551 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..a6972d6 --- /dev/null +++ b/src/analyze/core-js.ts @@ -0,0 +1,174 @@ +import {createRequire} from 'node:module'; +import {join, relative} from 'node:path'; +import {readFile, stat} from 'node:fs/promises'; +import {glob} from 'tinyglobby'; +import {minVersion} from 'semver'; +import type {AnalysisContext, ReportPluginResult} from '../types.js'; + +const cjsRequire = createRequire(import.meta.url); +const {compat, modules: allModules} = cjsRequire('core-js-compat') as { + compat: (opts: {targets: Record; inverse?: boolean}) => { + list: string[]; + }; + modules: string[]; +}; + +const {list: modernUnnecessary} = compat({ + targets: {chrome: '109', edge: '109', firefox: '109', safari: '16'}, + inverse: true +}); +const MODERN_UNNECESSARY_COUNT = modernUnnecessary.length; + +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} = compat({ + targets: {node: targetVersion}, + inverse: true + }); + const unnecessarySet = new Set(unnecessaryForTarget); + + const files = await glob(SOURCE_GLOB, { + cwd: context.root, + ignore: SOURCE_IGNORE, + absolute: true + }); + + for (const filePath of files) { + let source: string; + try { + source = await readFile(filePath, 'utf8'); + } catch { + continue; + } + + for (const [, specifier] of source.matchAll(IMPORT_RE)) { + if (BROAD_IMPORTS.has(specifier)) { + const rel = relative(context.root, filePath); + messages.push({ + severity: 'warning', + score: 0, + message: `Broad core-js import "${specifier}" in ${rel} 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)) { + const rel = relative(context.root, filePath); + messages.push({ + severity: 'suggestion', + score: 0, + message: `core-js polyfill "${moduleName}" imported in ${rel} is unnecessary — your Node.js target (>= ${targetVersion}) already supports this natively.` + }); + } + } + } + } + + return {messages}; +} + +export async function runVendoredCoreJsAnalysis( + context: AnalysisContext +): Promise { + const messages: ReportPluginResult['messages'] = []; + + if (!context.options?.buildDir) { + return {messages}; + } + + const buildDirAbs = join(context.root, context.options.buildDir); + let buildFiles: string[]; + try { + buildFiles = await glob('**/*.js', {cwd: buildDirAbs, absolute: true}); + } catch { + return {messages}; + } + + const totalPolyfills = allModules.length; + let totalVendoredBytes = 0; + + for (const filePath of buildFiles) { + let source: string; + let size: number; + try { + [source, {size}] = await Promise.all([ + readFile(filePath, 'utf8'), + stat(filePath) + ]); + } catch { + continue; + } + + if (!source.includes('Denis Pushkarev') && !source.includes('zloirock')) { + continue; + } + + totalVendoredBytes += size; + + const versionMatch = source.match(/version:"(\d+\.\d+\.\d+)"/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + const sizeKb = (size / 1024).toFixed(1); + const rel = relative(context.root, filePath); + messages.push({ + severity: 'warning', + score: 0, + message: `Vendored core-js ${version} detected in ${rel} (${sizeKb} KB). This bundle includes ${totalPolyfills} polyfills, of which ${MODERN_UNNECESSARY_COUNT} are unnecessary for modern browsers. Consider using a targeted polyfill strategy or removing core-js from your build.` + }); + } + + const stats: ReportPluginResult['stats'] = + totalVendoredBytes > 0 + ? { + extraStats: [ + { + name: 'vendoredPolyfillSize', + label: 'Vendored Polyfill Size', + value: totalVendoredBytes + } + ] + } + : undefined; + + return {messages, stats}; +} diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 0e69ceb..850f0ad 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -16,12 +16,15 @@ 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, runVendoredCoreJsAnalysis} from './core-js.js'; const plugins: ReportPlugin[] = [ runPublint, runReplacements, runDependencyAnalysis, - runDuplicateDependencyAnalysis + runDuplicateDependencyAnalysis, + runCoreJsAnalysis, + runVendoredCoreJsAnalysis ]; async function computeInfo(fileSystem: FileSystem) { diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 16a033c..d261b95 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -19,6 +19,11 @@ export const meta = { type: 'boolean', default: false, description: 'Output results as JSON to stdout' + }, + 'build-dir': { + type: 'string', + description: + 'Path to build output directory to scan for vendored polyfills (e.g. .next, dist, build)' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 9dc6eeb..b485e8e 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 buildDir = ctx.values['build-dir']; const {stats, messages} = await report({ root, - manifest: customManifests + manifest: customManifests, + buildDir }); 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..aab0be0 --- /dev/null +++ b/src/test/analyze/core-js.test.ts @@ -0,0 +1,441 @@ +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, + runVendoredCoreJsAnalysis +} 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]!; + +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); + expect(result.messages[0]!.severity).toBe('warning'); + expect(result.messages[0]!.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); + expect(result.messages[0]!.severity).toBe('suggestion'); + expect(result.messages[0]!.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]!; + + 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); + }); +}); + +describe('runVendoredCoreJsAnalysis', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('skips when buildDir is not set', async () => { + const context = makeContext(tempDir); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + expect(result.stats).toBeUndefined(); + }); + + it('skips when buildDir option is explicitly undefined', async () => { + const context = makeContext(tempDir, {options: {buildDir: undefined}}); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + }); + + it('returns no messages when build dir has no vendored core-js', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + await fs.writeFile( + path.join(buildDir, 'bundle.js'), + `console.log('hello world');\n` + ); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + expect(result.stats).toBeUndefined(); + }); + + it('warns when a vendored core-js file is detected via Denis Pushkarev', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + await fs.writeFile( + path.join(buildDir, 'bundle.js'), + `/* Denis Pushkarev */ var e={version:"3.35.0"};` + ); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.severity).toBe('warning'); + expect(result.messages[0]!.message).toContain('3.35.0'); + expect(result.messages[0]!.message).toContain('dist/bundle.js'); + }); + + it('warns when a vendored core-js file is detected via zloirock', async () => { + const buildDir = path.join(tempDir, '.next'); + await fs.mkdir(buildDir, {recursive: true}); + await fs.writeFile( + path.join(buildDir, 'chunks.js'), + `/* zloirock */ var e={version:"3.30.0"};` + ); + + const context = makeContext(tempDir, { + options: {buildDir: '.next'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.message).toContain('3.30.0'); + }); + + it('reports vendoredPolyfillSize in stats', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + const content = `/* Denis Pushkarev */ var e={version:"3.35.0"};`; + await fs.writeFile(path.join(buildDir, 'bundle.js'), content); + + const fileSize = Buffer.byteLength(content, 'utf8'); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.stats?.extraStats).toHaveLength(1); + expect(result.stats?.extraStats![0]!.name).toBe('vendoredPolyfillSize'); + expect(result.stats?.extraStats![0]!.label).toBe('Vendored Polyfill Size'); + expect(result.stats?.extraStats![0]!.value).toBe(fileSize); + }); + + it('accumulates size across multiple vendored files', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + const content = `/* Denis Pushkarev */ var e={version:"3.35.0"};`; + await fs.writeFile(path.join(buildDir, 'a.js'), content); + await fs.writeFile(path.join(buildDir, 'b.js'), content); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(2); + const totalSize = result.stats?.extraStats![0]!.value as number; + expect(totalSize).toBe(Buffer.byteLength(content, 'utf8') * 2); + }); + + it('handles a missing build directory gracefully', async () => { + const context = makeContext(tempDir, { + options: {buildDir: 'nonexistent'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(0); + expect(result.stats).toBeUndefined(); + }); + + it('reports unknown version when version string is absent', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + await fs.writeFile( + path.join(buildDir, 'bundle.js'), + `/* Denis Pushkarev - no version here */` + ); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages[0]!.message).toContain('unknown'); + }); +}); diff --git a/src/types.ts b/src/types.ts index ac96d5c..c5e860c 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[]; + buildDir?: string; } export interface StatLike { From d0ea974207bf15882956307ca7770bd2d8fcd55c Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 09:37:05 +0800 Subject: [PATCH 04/11] Update core-js.ts --- src/analyze/core-js.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index a6972d6..fe691c8 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -150,6 +150,7 @@ export async function runVendoredCoreJsAnalysis( const version = versionMatch ? versionMatch[1] : 'unknown'; const sizeKb = (size / 1024).toFixed(1); const rel = relative(context.root, filePath); + messages.push({ severity: 'warning', score: 0, From a294200092003f0519533db6f13529d84c9b2100 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 09:44:51 +0800 Subject: [PATCH 05/11] Fixed lint issues in test --- src/test/analyze/core-js.test.ts | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index aab0be0..8e02b6d 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -21,7 +21,9 @@ const unnecessaryForNode18 = compat({ targets: {node: '18.0.0'}, inverse: true }).list; -const unnecessaryModule = unnecessaryForNode18[0]!; +const unnecessaryModule = unnecessaryForNode18[0]; +if (!unnecessaryModule) + throw new Error('core-js-compat returned empty list for node 18'); function makeContext( tempDir: string, @@ -106,8 +108,10 @@ describe('runCoreJsAnalysis', () => { const result = await runCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.severity).toBe('warning'); - expect(result.messages[0]!.message).toContain('"core-js"'); + 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 () => { @@ -153,8 +157,10 @@ describe('runCoreJsAnalysis', () => { const result = await runCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.severity).toBe('suggestion'); - expect(result.messages[0]!.message).toContain(unnecessaryModule); + 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 () => { @@ -171,7 +177,7 @@ describe('runCoreJsAnalysis', () => { const result = await runCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.severity).toBe('warning'); + expect(result.messages[0]?.severity).toBe('warning'); }); it('emits no message for a core-js/modules import that is still needed', async () => { @@ -187,7 +193,10 @@ describe('runCoreJsAnalysis', () => { return; } - const neededModule = necessaryForOldNode[0]!; + const neededModule = necessaryForOldNode[0]; + expect(neededModule).toBeDefined(); + if (!neededModule) + throw new Error('necessaryForOldNode was unexpectedly empty'); await fs.writeFile( path.join(tempDir, 'index.js'), @@ -242,7 +251,7 @@ describe('runCoreJsAnalysis', () => { const result = await runCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.severity).toBe('suggestion'); + expect(result.messages[0]?.severity).toBe('suggestion'); }); it('falls back to current node version when engines.node is absent', async () => { @@ -345,9 +354,11 @@ describe('runVendoredCoreJsAnalysis', () => { const result = await runVendoredCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.severity).toBe('warning'); - expect(result.messages[0]!.message).toContain('3.35.0'); - expect(result.messages[0]!.message).toContain('dist/bundle.js'); + const vendoredMsg = result.messages[0]; + expect(vendoredMsg).toBeDefined(); + expect(vendoredMsg?.severity).toBe('warning'); + expect(vendoredMsg?.message).toContain('3.35.0'); + expect(vendoredMsg?.message).toContain('dist/bundle.js'); }); it('warns when a vendored core-js file is detected via zloirock', async () => { @@ -366,7 +377,7 @@ describe('runVendoredCoreJsAnalysis', () => { const result = await runVendoredCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]!.message).toContain('3.30.0'); + expect(result.messages[0]?.message).toContain('3.30.0'); }); it('reports vendoredPolyfillSize in stats', async () => { @@ -384,10 +395,11 @@ describe('runVendoredCoreJsAnalysis', () => { const result = await runVendoredCoreJsAnalysis(context); - expect(result.stats?.extraStats).toHaveLength(1); - expect(result.stats?.extraStats![0]!.name).toBe('vendoredPolyfillSize'); - expect(result.stats?.extraStats![0]!.label).toBe('Vendored Polyfill Size'); - expect(result.stats?.extraStats![0]!.value).toBe(fileSize); + const extraStats = result.stats?.extraStats; + expect(extraStats).toHaveLength(1); + expect(extraStats?.[0]?.name).toBe('vendoredPolyfillSize'); + expect(extraStats?.[0]?.label).toBe('Vendored Polyfill Size'); + expect(extraStats?.[0]?.value).toBe(fileSize); }); it('accumulates size across multiple vendored files', async () => { @@ -405,7 +417,7 @@ describe('runVendoredCoreJsAnalysis', () => { const result = await runVendoredCoreJsAnalysis(context); expect(result.messages).toHaveLength(2); - const totalSize = result.stats?.extraStats![0]!.value as number; + const totalSize = result.stats?.extraStats?.[0]?.value as number; expect(totalSize).toBe(Buffer.byteLength(content, 'utf8') * 2); }); @@ -436,6 +448,6 @@ describe('runVendoredCoreJsAnalysis', () => { const result = await runVendoredCoreJsAnalysis(context); - expect(result.messages[0]!.message).toContain('unknown'); + expect(result.messages[0]?.message).toContain('unknown'); }); }); From 6ee96de4be64f9a7eff1c064fb1738d15c3d62a7 Mon Sep 17 00:00:00 2001 From: Alexander Karan <47707063+AlexanderKaran@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:12:04 +0800 Subject: [PATCH 06/11] Update src/analyze/core-js.ts Co-authored-by: paul valladares <85648028+dreyfus92@users.noreply.github.com> --- src/analyze/core-js.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index fe691c8..cc213d0 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -154,7 +154,7 @@ export async function runVendoredCoreJsAnalysis( messages.push({ severity: 'warning', score: 0, - message: `Vendored core-js ${version} detected in ${rel} (${sizeKb} KB). This bundle includes ${totalPolyfills} polyfills, of which ${MODERN_UNNECESSARY_COUNT} are unnecessary for modern browsers. Consider using a targeted polyfill strategy or removing core-js from your build.` + message: `Vendored core-js ${version} detected in ${rel} (${sizeKb} KB). core-js ships ${totalPolyfills} total polyfills, ${MODERN_UNNECESSARY_COUNT} of which are unnecessary for modern browsers. Consider using a targeted polyfill strategy or removing core-js from your build.` }); } From 7dbceb966f3e5aad7a2df3557510e3023cffc9a4 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 12:18:18 +0800 Subject: [PATCH 07/11] Move to filesystem --- src/analyze/core-js.ts | 20 ++++++++------------ src/file-system.ts | 1 + src/local-file-system.ts | 5 +++++ src/test/local-file-system.test.ts | 15 +++++++++++++++ src/test/plugin-runner.test.ts | 3 ++- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index cc213d0..31a589f 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -1,6 +1,5 @@ import {createRequire} from 'node:module'; import {join, relative} from 'node:path'; -import {readFile, stat} from 'node:fs/promises'; import {glob} from 'tinyglobby'; import {minVersion} from 'semver'; import type {AnalysisContext, ReportPluginResult} from '../types.js'; @@ -71,34 +70,31 @@ export async function runCoreJsAnalysis( const files = await glob(SOURCE_GLOB, { cwd: context.root, - ignore: SOURCE_IGNORE, - absolute: true + ignore: SOURCE_IGNORE }); for (const filePath of files) { let source: string; try { - source = await readFile(filePath, 'utf8'); + source = await context.fs.readFile(filePath); } catch { continue; } for (const [, specifier] of source.matchAll(IMPORT_RE)) { if (BROAD_IMPORTS.has(specifier)) { - const rel = relative(context.root, filePath); messages.push({ severity: 'warning', score: 0, - message: `Broad core-js import "${specifier}" in ${rel} loads all polyfills at once. Import only the specific modules you need.` + 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)) { - const rel = relative(context.root, filePath); messages.push({ severity: 'suggestion', score: 0, - message: `core-js polyfill "${moduleName}" imported in ${rel} is unnecessary — your Node.js target (>= ${targetVersion}) already supports this natively.` + message: `core-js polyfill "${moduleName}" imported in ${filePath} is unnecessary — your Node.js target (>= ${targetVersion}) already supports this natively.` }); } } @@ -129,12 +125,13 @@ export async function runVendoredCoreJsAnalysis( let totalVendoredBytes = 0; for (const filePath of buildFiles) { + const rel = relative(context.root, filePath); let source: string; let size: number; try { - [source, {size}] = await Promise.all([ - readFile(filePath, 'utf8'), - stat(filePath) + [source, size] = await Promise.all([ + context.fs.readFile(rel), + context.fs.getFileSize(rel) ]); } catch { continue; @@ -149,7 +146,6 @@ export async function runVendoredCoreJsAnalysis( const versionMatch = source.match(/version:"(\d+\.\d+\.\d+)"/); const version = versionMatch ? versionMatch[1] : 'unknown'; const sizeKb = (size / 1024).toFixed(1); - const rel = relative(context.root, filePath); messages.push({ severity: 'warning', diff --git a/src/file-system.ts b/src/file-system.ts index 3d1c68f..0806b5f 100644 --- a/src/file-system.ts +++ b/src/file-system.ts @@ -4,4 +4,5 @@ export interface FileSystem { readFile(path: string): Promise; getInstallSize(): Promise; fileExists(path: string): Promise; + getFileSize(path: string): Promise; } diff --git a/src/local-file-system.ts b/src/local-file-system.ts index 0443831..4cadc27 100644 --- a/src/local-file-system.ts +++ b/src/local-file-system.ts @@ -83,4 +83,9 @@ export class LocalFileSystem implements FileSystem { return false; } } + + async getFileSize(filePath: string): Promise { + const {size} = await stat(path.join(this.#root, filePath)); + return size; + } } diff --git a/src/test/local-file-system.test.ts b/src/test/local-file-system.test.ts index d3a48b7..897d989 100644 --- a/src/test/local-file-system.test.ts +++ b/src/test/local-file-system.test.ts @@ -15,6 +15,21 @@ describe('LocalFileSystem', () => { await fs.rm(tempDir, {recursive: true, force: true}); }); + describe('getFileSize', () => { + it('returns the byte size of an existing file', async () => { + const content = 'hello world'; + await fs.writeFile(path.join(tempDir, 'file.txt'), content); + const fileSystem = new LocalFileSystem(tempDir); + const size = await fileSystem.getFileSize('file.txt'); + expect(size).toBe(Buffer.byteLength(content, 'utf8')); + }); + + it('throws for a non-existent file', async () => { + const fileSystem = new LocalFileSystem(tempDir); + await expect(fileSystem.getFileSize('missing.txt')).rejects.toThrow(); + }); + }); + describe('fileExists', () => { it('should return false when tsconfig.json does not exist', async () => { const fileSystem = new LocalFileSystem(tempDir); diff --git a/src/test/plugin-runner.test.ts b/src/test/plugin-runner.test.ts index 7c68c39..96ab280 100644 --- a/src/test/plugin-runner.test.ts +++ b/src/test/plugin-runner.test.ts @@ -8,7 +8,8 @@ const fsMock: FileSystem = { listPackageFiles: async () => [], readFile: async () => '', getInstallSize: async () => 0, - fileExists: async () => false + fileExists: async () => false, + getFileSize: async () => 0 }; const depCounts = {production: 0, development: 0}; From c4679237f35425b9ef8e1e22b2cb7c401345fbc1 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 12:27:36 +0800 Subject: [PATCH 08/11] Change target --- src/analyze/core-js.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index 31a589f..feef019 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -13,7 +13,7 @@ const {compat, modules: allModules} = cjsRequire('core-js-compat') as { }; const {list: modernUnnecessary} = compat({ - targets: {chrome: '109', edge: '109', firefox: '109', safari: '16'}, + targets: 'last 2 versions', inverse: true }); const MODERN_UNNECESSARY_COUNT = modernUnnecessary.length; From cea0f8e8189a8c376aa0e2675d29a3f0c0c0a9de Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 12:34:43 +0800 Subject: [PATCH 09/11] Added comment --- src/analyze/core-js.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index feef019..a93680b 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -137,6 +137,8 @@ export async function runVendoredCoreJsAnalysis( continue; } + // core-js embeds these strings as runtime literals in its version metadata, so they + // typically survive minification — but aggressive dead-code elimination could remove them. if (!source.includes('Denis Pushkarev') && !source.includes('zloirock')) { continue; } From 7c65fcf694e19c3c072d3e12d6bf68cf78ce050c Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sat, 7 Mar 2026 13:28:15 +0800 Subject: [PATCH 10/11] More detection --- src/analyze/core-js.ts | 17 ++++++++++---- src/test/analyze/core-js.test.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index a93680b..9df68f5 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -6,7 +6,10 @@ import type {AnalysisContext, ReportPluginResult} from '../types.js'; const cjsRequire = createRequire(import.meta.url); const {compat, modules: allModules} = cjsRequire('core-js-compat') as { - compat: (opts: {targets: Record; inverse?: boolean}) => { + compat: (opts: { + targets: Record | string; + inverse?: boolean; + }) => { list: string[]; }; modules: string[]; @@ -137,9 +140,15 @@ export async function runVendoredCoreJsAnalysis( continue; } - // core-js embeds these strings as runtime literals in its version metadata, so they - // typically survive minification — but aggressive dead-code elimination could remove them. - if (!source.includes('Denis Pushkarev') && !source.includes('zloirock')) { + // Detection uses multiple signals: author strings and __core-js_shared__ are runtime + // literals/globals that typically survive minification; mode:"global" is structural + // to core-js version metadata. Aggressive dead-code elimination could still remove them. + if ( + !source.includes('Denis Pushkarev') && + !source.includes('zloirock') && + !source.includes('__core-js_shared__') && + !source.includes('mode:"global"') + ) { continue; } diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index 8e02b6d..038feaf 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -361,6 +361,44 @@ describe('runVendoredCoreJsAnalysis', () => { expect(vendoredMsg?.message).toContain('dist/bundle.js'); }); + it('warns when a vendored core-js file is detected via __core-js_shared__', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + await fs.writeFile( + path.join(buildDir, 'bundle.js'), + `globalThis["__core-js_shared__"]={};var e={version:"3.36.0"};` + ); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('3.36.0'); + }); + + it('warns when a vendored core-js file is detected via mode:"global"', async () => { + const buildDir = path.join(tempDir, 'dist'); + await fs.mkdir(buildDir); + await fs.writeFile( + path.join(buildDir, 'bundle.js'), + `(r.versions||(r.versions=[])).push({version:"3.37.0",mode:"global"});` + ); + + const context = makeContext(tempDir, { + options: {buildDir: 'dist'}, + packageFile: {name: 'test-package', version: '1.0.0'} + }); + + const result = await runVendoredCoreJsAnalysis(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('3.37.0'); + }); + it('warns when a vendored core-js file is detected via zloirock', async () => { const buildDir = path.join(tempDir, '.next'); await fs.mkdir(buildDir, {recursive: true}); From 23c993328b6754a6d2d3728611efc14704bf2cb7 Mon Sep 17 00:00:00 2001 From: Alexander Karan Date: Sun, 8 Mar 2026 07:02:34 +0800 Subject: [PATCH 11/11] Import --- src/analyze/core-js.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index 9df68f5..db0295c 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -1,21 +1,11 @@ -import {createRequire} from 'node:module'; import {join, relative} from 'node:path'; import {glob} from 'tinyglobby'; import {minVersion} from 'semver'; import type {AnalysisContext, ReportPluginResult} from '../types.js'; -const cjsRequire = createRequire(import.meta.url); -const {compat, modules: allModules} = cjsRequire('core-js-compat') as { - compat: (opts: { - targets: Record | string; - inverse?: boolean; - }) => { - list: string[]; - }; - modules: string[]; -}; - -const {list: modernUnnecessary} = compat({ +import coreJsCompat from 'core-js-compat'; + +const {list: modernUnnecessary} = coreJsCompat.compat({ targets: 'last 2 versions', inverse: true }); @@ -65,7 +55,7 @@ export async function runCoreJsAnalysis( } } - const {list: unnecessaryForTarget} = compat({ + const {list: unnecessaryForTarget} = coreJsCompat.compat({ targets: {node: targetVersion}, inverse: true }); @@ -124,7 +114,7 @@ export async function runVendoredCoreJsAnalysis( return {messages}; } - const totalPolyfills = allModules.length; + const totalPolyfills = coreJsCompat.modules.length; let totalVendoredBytes = 0; for (const filePath of buildFiles) {