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..db0295c --- /dev/null +++ b/src/analyze/core-js.ts @@ -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 { + 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 { + 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}; +} 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/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/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts new file mode 100644 index 0000000..038feaf --- /dev/null +++ b/src/test/analyze/core-js.test.ts @@ -0,0 +1,491 @@ +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]; +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); + }); +}); + +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); + 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 __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}); + 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); + + 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 () => { + 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/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}; 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 {