diff --git a/package-lock.json b/package-lock.json index 9941239..53d0224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@e18e/web-features-codemods": "^0.2.0", "@publint/pack": "^0.1.4", "core-js-compat": "^3.48.0", + "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.32.0", @@ -2240,9 +2241,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2274,9 +2275,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -2293,11 +2294,11 @@ ], "license": "MIT", "dependencies": { - "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" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2313,9 +2314,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "funding": [ { "type": "opencollective", @@ -2471,11 +2472,24 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "license": "ISC" }, + "node_modules/enginematch": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.5.tgz", + "integrity": "sha512-Q1a8Kqxg9LPw4Rr5OV5ggO+XazMYcM+tndr5upq4dJ2F1FNyNhmaQLAUYc/gr09F/vJ28r0k/Tk2CwehdERhrw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "semver": "^7.8.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", diff --git a/package.json b/package.json index c539a46..1488eb7 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,9 @@ "dependencies": { "@clack/prompts": "^1.4.0", "@e18e/web-features-codemods": "^0.2.0", - "fast-wrap-ansi": "^0.2.0", "@publint/pack": "^0.1.4", + "core-js-compat": "^3.48.0", + "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.32.0", @@ -59,7 +60,6 @@ "obug": "^2.1.1", "package-manager-detector": "^1.6.0", "publint": "^0.3.21", - "core-js-compat": "^3.48.0", "semver": "^7.8.0", "tinyglobby": "^0.2.16" }, diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 7c40c49..6101ac8 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -4,17 +4,12 @@ import type { EngineConstraint, KnownUrl } from 'module-replacements'; +import {type PackageJson, satisfies} from 'enginematch'; import type {ReportPluginResult, AnalysisContext} from '../types.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; import {getManifestForCategories} from '../categories.js'; import {resolve, dirname, basename} from 'node:path'; -import { - satisfies as semverSatisfies, - ltr as semverLessThan, - minVersion, - validRange -} from 'semver'; import {LocalFileSystem} from '../local-file-system.js'; /** @@ -32,44 +27,23 @@ export function resolveUrl(url: KnownUrl): string { } } -function getNodeMinVersion(engines?: EngineConstraint[]): string | undefined { +function getNodeJSMinVersion(engines?: EngineConstraint[]): string | undefined { return engines?.find((e) => e.engine === 'nodejs')?.minVersion; } -function isNodeEngineCompatible( - requiredNode: string, - enginesNode: string -): boolean { - const requiredRange = validRange(requiredNode); - const engineRange = validRange(enginesNode); - - if (!requiredRange || !engineRange) { - return true; - } - - const requiredMin = minVersion(requiredRange); - if (!requiredMin) { - return true; - } - - return ( - semverLessThan(requiredMin.version, engineRange) || - semverSatisfies(requiredMin.version, engineRange) - ); -} - function findFirstCompatibleReplacement( replacementIds: string[], defs: Record, - enginesNode: string | undefined + pkg: PackageJson, + root: string ): ModuleReplacement | undefined { for (const id of replacementIds) { const replacement = defs[id]; if (!replacement) continue; - if (replacement.type === 'native' && enginesNode) { - const nodeVersion = getNodeMinVersion(replacement.engines); - if (nodeVersion && !isNodeEngineCompatible(nodeVersion, enginesNode)) { + const reqs = replacement.engines; + if (reqs?.length) { + if (!satisfies(pkg, {requirements: reqs, cwd: root})) { continue; } } @@ -157,7 +131,8 @@ export async function runReplacements( const firstCompatible = findFirstCompatibleReplacement( mapping.replacements, allReplacementDefs, - enginesNode + packageJson as PackageJson, + context.root ); if (!firstCompatible) { continue; @@ -175,7 +150,7 @@ export async function runReplacements( message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`; break; case 'native': { - const nodeVersion = getNodeMinVersion(firstCompatible.engines); + const nodeVersion = getNodeJSMinVersion(firstCompatible.engines); const requires = nodeVersion && !enginesNode ? ` Required Node >= ${nodeVersion}.` diff --git a/src/test/analyze/replacements.test.ts b/src/test/analyze/replacements.test.ts new file mode 100644 index 0000000..491ef79 --- /dev/null +++ b/src/test/analyze/replacements.test.ts @@ -0,0 +1,190 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {runReplacements} from '../../analyze/replacements.js'; +import {LocalFileSystem} from '../../local-file-system.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; +import type {AnalysisContext, PackageJsonLike} from '../../types.js'; + +const MANIFEST = { + mappings: { + 'legacy-pkg': { + type: 'module', + moduleName: 'legacy-pkg', + replacements: ['legacy-native'] + }, + 'old-browser-pkg': { + type: 'module', + moduleName: 'old-browser-pkg', + replacements: ['modern-browser-native'] + } + }, + replacements: { + 'legacy-native': { + id: 'legacy-native', + type: 'simple', + description: 'use the native equivalent', + engines: [{engine: 'nodejs', minVersion: '20.0.0'}] + }, + 'modern-browser-native': { + id: 'modern-browser-native', + type: 'simple', + description: 'use the native browser API', + engines: [{engine: 'chrome', minVersion: '100.0.0'}] + } + } +}; + +async function writeManifest(root: string): Promise { + const manifestPath = path.join(root, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(MANIFEST)); + return manifestPath; +} + +async function setupContext( + root: string, + packageFile: PackageJsonLike, + manifestPath: string +): Promise { + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify(packageFile) + ); + return makeContext(root, packageFile, manifestPath); +} + +function makeContext( + root: string, + packageFile: PackageJsonLike, + manifestPath: string +): AnalysisContext { + return { + fs: new LocalFileSystem(root), + root, + messages: [], + stats: { + name: packageFile.name, + version: packageFile.version, + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + lockfile: { + type: 'npm', + packages: [], + root: { + name: packageFile.name, + version: packageFile.version, + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile, + options: {manifest: [manifestPath]} + }; +} + +describe('runReplacements engine filtering', () => { + let tempDir: string; + let manifestPath: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + manifestPath = await writeManifest(tempDir); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('emits a replacement when engines.node satisfies the requirement', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'}, + engines: {node: '>=20'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('legacy-pkg'); + }); + + it('filters out a replacement when engines.node does not satisfy the requirement', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'}, + engines: {node: '>=16'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); + + it('emits a replacement when no engines are declared (constraint trivially satisfied)', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + }); + + it('discovers .browserslistrc from cwd to filter a replacement', async () => { + await fs.writeFile( + path.join(tempDir, '.browserslistrc'), + 'chrome >= 110\n' + ); + + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('old-browser-pkg'); + }); + + it('filters via .browserslistrc when the resolved browser version is too low', async () => { + await fs.writeFile(path.join(tempDir, '.browserslistrc'), 'chrome >= 90\n'); + + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); + + it('uses package.json browserslist field to filter a replacement', async () => { + const pkg = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'}, + browserslist: ['chrome >= 90'] + } as PackageJsonLike; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); +}); diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts index 5d95f8d..b125c47 100644 --- a/src/test/custom-manifests.test.ts +++ b/src/test/custom-manifests.test.ts @@ -15,9 +15,10 @@ describe('Custom Manifests', () => { const testDir = join(__dirname, '../../test/fixtures/fake-modules'); const fileSystem = new LocalFileSystem(testDir); + const pkg = {name: 'test-package', version: '1.0.0' as const}; context = { fs: fileSystem, - root: '.', + root: testDir, messages: [], stats: { name: 'unknown', @@ -40,10 +41,7 @@ describe('Custom Manifests', () => { peerDependencies: [] } }, - packageFile: { - name: 'test-package', - version: '1.0.0' - } + packageFile: pkg }; });