diff --git a/package-lock.json b/package-lock.json index 48c14a5..3c8db43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "fdir": "^6.5.0", "gunshi": "^0.29.2", "lockparse": "^0.5.0", - "module-replacements": "^2.11.0", + "module-replacements": "^3.0.0-beta.0", "module-replacements-codemods": "^1.2.0", "obug": "^2.1.1", "package-manager-detector": "^1.6.0", @@ -4054,9 +4054,9 @@ } }, "node_modules/module-replacements": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz", - "integrity": "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==", + "version": "3.0.0-beta.0", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-3.0.0-beta.0.tgz", + "integrity": "sha512-T/REIieQTHKscHccdxRd5A2mYIPPIVuKq652gyAZK1D2hmRCWUi7whDskKiA87knsoCWDDMzjcJHqsCXNWr0Qw==", "license": "MIT" }, "node_modules/module-replacements-codemods": { diff --git a/package.json b/package.json index 0bbb896..cb907fb 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "fdir": "^6.5.0", "gunshi": "^0.29.2", "lockparse": "^0.5.0", - "module-replacements": "^2.11.0", + "module-replacements": "^3.0.0-beta.0", "module-replacements-codemods": "^1.2.0", "obug": "^2.1.1", "package-manager-detector": "^1.6.0", diff --git a/scripts/generate-fixable-replacements.ts b/scripts/generate-fixable-replacements.ts index 0d8f084..03a0f26 100644 --- a/scripts/generate-fixable-replacements.ts +++ b/scripts/generate-fixable-replacements.ts @@ -3,29 +3,38 @@ import {join, dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; import {all} from 'module-replacements'; import {codemods} from 'module-replacements-codemods'; -import {fixableReplacements} from '../lib/commands/fixable-replacements.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -async function generateFixableReplacements() { - const existingReplacements = new Map( - fixableReplacements.map((r) => [r.from, r]) - ); +function getReplacementTarget(moduleName: string): string { + const mapping = all.mappings[moduleName]; + if (!mapping?.replacements?.length) return moduleName; + + const firstId = mapping.replacements[0]!; + const replacement = all.replacements[firstId]; + if (!replacement) return firstId; + + if (replacement.type === 'documented' && replacement.replacementModule) { + return replacement.replacementModule; + } + + return replacement.id; +} +async function generateFixableReplacements() { let newCode = `import type { Replacement } from '../types.js';\n`; newCode += `import { codemods } from 'module-replacements-codemods';\n\n`; newCode += `export const fixableReplacements: Replacement[] = [\n`; let count = 0; - for (const replacement of all.moduleReplacements) { - if (replacement.moduleName in codemods) { - const existing = existingReplacements.get(replacement.moduleName); - const to = existing?.to ?? 'TODO'; + for (const moduleName of Object.keys(all.mappings)) { + if (moduleName in codemods) { + const to = getReplacementTarget(moduleName); newCode += ` {\n`; - newCode += ` from: '${replacement.moduleName}',\n`; + newCode += ` from: '${moduleName}',\n`; newCode += ` to: '${to}',\n`; - newCode += ` factory: codemods['${replacement.moduleName}']\n`; + newCode += ` factory: codemods['${moduleName}']\n`; newCode += ` },\n`; count++; } diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index a3e5e44..0e09bef 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -1,5 +1,10 @@ import * as replacements from 'module-replacements'; -import type {ManifestModule, ModuleReplacement} from 'module-replacements'; +import type { + ManifestModule, + ModuleReplacement, + EngineConstraint, + KnownUrl +} from 'module-replacements'; import type {ReportPluginResult, AnalysisContext} from '../types.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; @@ -13,27 +18,74 @@ import { import {LocalFileSystem} from '../local-file-system.js'; /** - * Generates a standard URL to the docs of a given rule - * @param {string} name Rule name - * @return {string} + * Resolves a v3 KnownUrl to a full URL string. */ -export function getDocsUrl(name: string): string { - return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${name}.md`; +export function resolveUrl(url: KnownUrl): string { + if (typeof url === 'string') return url; + switch (url.type) { + case 'mdn': + return `https://developer.mozilla.org/en-US/docs/${url.id}`; + case 'node': + return `https://nodejs.org/docs/latest/${url.id}`; + case 'e18e': + return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${url.id}.md`; + } } -/** - * Generates a URL for the given path on MDN - * @param {string} path Docs path - * @return {string} - */ -export function getMdnUrl(path: string): string { - return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${path}`; +function getNodeMinVersion(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 +): 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)) { + continue; + } + } + + return replacement; + } + return undefined; } async function loadCustomManifests( manifestPaths: string[] -): Promise { - const customReplacements: ModuleReplacement[] = []; +): Promise { + const result: ManifestModule = { + mappings: {}, + replacements: {} + }; for (const manifestPath of manifestPaths) { try { @@ -46,11 +98,11 @@ async function loadCustomManifests( ); const manifest: ManifestModule = JSON.parse(manifestContent); - if ( - manifest.moduleReplacements && - Array.isArray(manifest.moduleReplacements) - ) { - customReplacements.push(...manifest.moduleReplacements); + if (manifest.mappings) { + Object.assign(result.mappings, manifest.mappings); + } + if (manifest.replacements) { + Object.assign(result.replacements, manifest.replacements); } } catch (error) { console.warn( @@ -59,29 +111,7 @@ async function loadCustomManifests( } } - return customReplacements; -} - -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) - ); + return result; } export async function runReplacements( @@ -94,89 +124,82 @@ export async function runReplacements( const packageJson = await getPackageJson(context.fs); if (!packageJson || !packageJson.dependencies) { - // No dependencies return result; } - // Load custom manifests - const customReplacements = context.options?.manifest + const customManifest = context.options?.manifest ? await loadCustomManifests(context.options.manifest) - : []; + : {mappings: {}, replacements: {}}; - // Combine custom and built-in replacements - const allReplacements = [ - ...customReplacements, - ...replacements.all.moduleReplacements - ]; + // Custom mappings take precedence over built-in + const allMappings = { + ...replacements.all.mappings, + ...customManifest.mappings + }; + const allReplacementDefs: Record = { + ...replacements.all.replacements, + ...customManifest.replacements + }; const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from)); + const enginesNode = packageJson.engines?.node; for (const name of Object.keys(packageJson.dependencies)) { - // Find replacement (custom replacements take precedence due to order) - const replacement = allReplacements.find( - (replacement) => replacement.moduleName === name - ); + const mapping = allMappings[name]; + if (!mapping?.replacements?.length) { + continue; + } - if (!replacement) { + const firstCompatible = findFirstCompatibleReplacement( + mapping.replacements, + allReplacementDefs, + enginesNode + ); + if (!firstCompatible) { continue; } const fixableBy = fixableByMigrate.has(name) ? 'migrate' : undefined; - - // Handle each replacement type using the same logic for both custom and built-in - if (replacement.type === 'none') { - result.messages.push({ - severity: 'warning', - score: 0, - message: `Module "${name}" can be removed, and native functionality used instead`, - ...(fixableBy && {fixableBy}) - }); - } else if (replacement.type === 'simple') { - result.messages.push({ - severity: 'warning', - score: 0, - message: `Module "${name}" can be replaced. ${replacement.replacement}.`, - ...(fixableBy && {fixableBy}) - }); - } else if (replacement.type === 'native') { - const enginesNode = packageJson.engines?.node; - let supported = true; - - if (replacement.nodeVersion && enginesNode) { - supported = isNodeEngineCompatible( - replacement.nodeVersion, - enginesNode - ); - } - - if (!supported) { - continue; + const mappingUrl = mapping.url ? resolveUrl(mapping.url) : undefined; + + let message: string; + switch (firstCompatible.type) { + case 'removal': + message = `Module "${name}" can be removed, and native functionality used instead`; + break; + case 'simple': + message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`; + break; + case 'native': { + const nodeVersion = getNodeMinVersion(firstCompatible.engines); + const requires = + nodeVersion && !enginesNode + ? ` Required Node >= ${nodeVersion}.` + : ''; + const urlStr = resolveUrl(firstCompatible.url); + message = `Module "${name}" can be replaced with native functionality.${requires} You can read more at ${urlStr}.`; + break; } - - const mdnPath = getMdnUrl(replacement.mdnPath); - const requires = - replacement.nodeVersion && !enginesNode - ? ` Required Node >= ${replacement.nodeVersion}.` - : ''; - const message = `Module "${name}" can be replaced with native functionality. Use "${replacement.replacement}" instead.${requires}`; - const fullMessage = `${message} You can read more at ${mdnPath}.`; - result.messages.push({ - severity: 'warning', - score: 0, - message: fullMessage, - ...(fixableBy && {fixableBy}) - }); - } else if (replacement.type === 'documented') { - const docUrl = getDocsUrl(replacement.docPath); - const message = `Module "${name}" can be replaced with a more performant alternative.`; - const fullMessage = `${message} See the list of available alternatives at ${docUrl}.`; - result.messages.push({ - severity: 'warning', - score: 0, - message: fullMessage, - ...(fixableBy && {fixableBy}) - }); + case 'documented': + if (firstCompatible.replacementModule) { + message = `Module "${name}" can be replaced. We recommend switching to "${firstCompatible.replacementModule}".`; + } else { + const urlStr = resolveUrl(firstCompatible.url); + message = `Module "${name}" can be replaced with a more performant alternative. See the list of available alternatives at ${urlStr}.`; + } + break; + default: + message = `Module "${name}" can be replaced.`; + } + if (mappingUrl) { + message += ` See more at ${mappingUrl}.`; } + result.messages.push({ + severity: 'warning', + score: 0, + message, + ...(fixableBy && {fixableBy}) + }); } return result; diff --git a/src/test/__snapshots__/custom-manifests.test.ts.snap b/src/test/__snapshots__/custom-manifests.test.ts.snap index 9c4d3f3..0200bda 100644 --- a/src/test/__snapshots__/custom-manifests.test.ts.snap +++ b/src/test/__snapshots__/custom-manifests.test.ts.snap @@ -5,22 +5,22 @@ exports[`Custom Manifests > should handle invalid manifest files gracefully 1`] exports[`Custom Manifests > should load and use custom manifest files 1`] = ` [ { - "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", + "message": "Module "@e18e/fake-3" can be replaced. We recommend switching to "node-fetch". See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, @@ -35,22 +35,22 @@ exports[`Custom Manifests > should load and use custom manifest files 1`] = ` exports[`Custom Manifests > should load multiple manifest files 1`] = ` [ { - "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", + "message": "Module "@e18e/fake-3" can be replaced. We recommend switching to "node-fetch". See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, @@ -60,7 +60,7 @@ exports[`Custom Manifests > should load multiple manifest files 1`] = ` "severity": "warning", }, { - "message": "Module "@e18e/fake-5" can be replaced. Use Fastify, Koa, or native Node.js http module.", + "message": "Module "@e18e/fake-5" can be replaced with inline native syntax. Use Fastify, Koa, or native Node.js http module.", "score": 0, "severity": "warning", }, @@ -76,22 +76,22 @@ exports[`Custom Manifests > should prioritize custom replacements over built-in { "withCustom": [ { - "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { - "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", + "message": "Module "@e18e/fake-3" can be replaced. We recommend switching to "node-fetch". See more at https://github.com/es-tooling/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, diff --git a/test/fixtures/custom-manifest-2.json b/test/fixtures/custom-manifest-2.json index a979190..bd828f1 100644 --- a/test/fixtures/custom-manifest-2.json +++ b/test/fixtures/custom-manifest-2.json @@ -1,14 +1,26 @@ { - "moduleReplacements": [ - { + "mappings": { + "@e18e/fake-5": { + "type": "module", "moduleName": "@e18e/fake-5", - "type": "simple", - "replacement": "Use Fastify, Koa, or native Node.js http module" + "replacements": ["fake-5-simple"] }, - { + "@e18e/fake-6": { + "type": "module", "moduleName": "@e18e/fake-6", - "type": "none", - "replacement": "Use native JSON.parse or built-in middleware" + "replacements": ["fake-6-removal"] + } + }, + "replacements": { + "fake-5-simple": { + "id": "fake-5-simple", + "type": "simple", + "description": "Use Fastify, Koa, or native Node.js http module" + }, + "fake-6-removal": { + "id": "fake-6-removal", + "type": "removal", + "description": "Use native JSON.parse or built-in middleware" } - ] + } } diff --git a/test/fixtures/custom-manifest.json b/test/fixtures/custom-manifest.json index 6170e1a..dcc138f 100644 --- a/test/fixtures/custom-manifest.json +++ b/test/fixtures/custom-manifest.json @@ -1,32 +1,69 @@ { - "moduleReplacements": [ - { + "mappings": { + "@e18e/fake-0": { + "type": "module", "moduleName": "@e18e/fake-0", - "type": "simple", - "replacement": "Use picocolors, kleur, or native console styling" + "replacements": ["fake-0-simple"], + "url": { "type": "e18e", "id": "fake-0" } }, - { + "@e18e/fake-1": { + "type": "module", "moduleName": "@e18e/fake-1", - "type": "simple", - "replacement": "Use native JavaScript methods or specific lodash functions" + "replacements": ["fake-1-simple"] }, - { + "@e18e/fake-2": { + "type": "module", "moduleName": "@e18e/fake-2", - "type": "native", - "replacement": "Intl.DateTimeFormat or Date methods", - "mdnPath": "Global_Objects/Intl/DateTimeFormat", - "nodeVersion": "12.0.0" + "replacements": ["fake-2-native"] }, - { + "@e18e/fake-3": { + "type": "module", "moduleName": "@e18e/fake-3", - "type": "documented", - "replacement": "node-fetch, axios, or native fetch", - "docPath": "request-alternatives" + "replacements": ["fake-3-documented"], + "url": { "type": "e18e", "id": "request-alternatives" } }, - { + "@e18e/fake-4": { + "type": "module", "moduleName": "@e18e/fake-4", - "type": "none", - "replacement": "Use native Promise methods" + "replacements": ["fake-4-removal"] + } + }, + "replacements": { + "fake-0-simple": { + "id": "fake-0-simple", + "type": "simple", + "description": "Use picocolors, kleur, or native console styling" + }, + "fake-1-simple": { + "id": "fake-1-simple", + "type": "simple", + "description": "Use native JavaScript methods or specific lodash functions" + }, + "fake-2-native": { + "id": "fake-2-native", + "type": "native", + "description": "Intl.DateTimeFormat or Date methods", + "url": { + "type": "mdn", + "id": "Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat" + }, + "engines": [ + { "engine": "nodejs", "minVersion": "12.0.0" } + ] + }, + "fake-3-documented": { + "id": "fake-3-documented", + "type": "documented", + "url": { + "type": "e18e", + "id": "request-alternatives" + }, + "replacementModule": "node-fetch" + }, + "fake-4-removal": { + "id": "fake-4-removal", + "type": "removal", + "description": "Use native Promise methods" } - ] + } }