Skip to content
65 changes: 46 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
172 changes: 172 additions & 0 deletions src/analyze/core-js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {join, relative} 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 {list: modernUnnecessary} = coreJsCompat.compat({
targets: 'last 2 versions',
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<ReportPluginResult> {
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 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};
}

export async function runVendoredCoreJsAnalysis(
context: AnalysisContext
): Promise<ReportPluginResult> {
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 = coreJsCompat.modules.length;
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([
context.fs.readFile(rel),
context.fs.getFileSize(rel)
]);
} catch {
continue;
}

// 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;
}

totalVendoredBytes += size;

const versionMatch = source.match(/version:"(\d+\.\d+\.\d+)"/);
const version = versionMatch ? versionMatch[1] : 'unknown';
const sizeKb = (size / 1024).toFixed(1);

messages.push({
severity: 'warning',
score: 0,
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.`
});
}

const stats: ReportPluginResult['stats'] =
totalVendoredBytes > 0
? {
extraStats: [
{
name: 'vendoredPolyfillSize',
label: 'Vendored Polyfill Size',
value: totalVendoredBytes
}
]
}
: undefined;

return {messages, stats};
}
5 changes: 4 additions & 1 deletion src/analyze/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/commands/analyze.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 3 additions & 1 deletion src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ export async function run(ctx: CommandContext<typeof meta>) {

// 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;
Expand Down
1 change: 1 addition & 0 deletions src/file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface FileSystem {
readFile(path: string): Promise<string>;
getInstallSize(): Promise<number>;
fileExists(path: string): Promise<boolean>;
getFileSize(path: string): Promise<number>;
}
5 changes: 5 additions & 0 deletions src/local-file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,9 @@ export class LocalFileSystem implements FileSystem {
return false;
}
}

async getFileSize(filePath: string): Promise<number> {
const {size} = await stat(path.join(this.#root, filePath));
return size;
}
}
Loading
Loading