Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 20 additions & 11 deletions scripts/generate-fixable-replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
Expand Down
238 changes: 128 additions & 110 deletions src/analyze/replacements.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, ModuleReplacement>,
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<ModuleReplacement[]> {
const customReplacements: ModuleReplacement[] = [];
): Promise<ManifestModule> {
const result: ManifestModule = {
mappings: {},
replacements: {}
};

for (const manifestPath of manifestPaths) {
try {
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -94,89 +124,77 @@ 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<string, ModuleReplacement> = {
...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':
message = `Module "${name}" can be replaced with a more performant alternative.`;
break;
default:
message = `Module "${name}" can be replaced with a more performant alternative.`;
}
if (mappingUrl) {
message += ` See more at ${mappingUrl}.`;
}
result.messages.push({
severity: 'warning',
score: 0,
message,
...(fixableBy && {fixableBy})
});
}

return result;
Expand Down
Loading
Loading