Skip to content
Open
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
210 changes: 122 additions & 88 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,86 +124,90 @@ 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;
const mappingUrl = mapping.url ? resolveUrl(mapping.url) : undefined;

// Handle each replacement type using the same logic for both custom and built-in
if (replacement.type === 'none') {
if (mappingUrl) {
let message = `Module "${name}" can be replaced.`;
if (
firstCompatible.type === 'documented' &&
firstCompatible.replacementModule
) {
message += ` We recommend switching to "${firstCompatible.replacementModule}".`;
}
message += ` See more at ${mappingUrl}.`;
result.messages.push({
severity: 'warning',
score: 0,
message,
...(fixableBy && {fixableBy})
});
} else if (firstCompatible.type === 'removal') {
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') {
} else if (firstCompatible.type === 'simple') {
result.messages.push({
severity: 'warning',
score: 0,
message: `Module "${name}" can be replaced. ${replacement.replacement}.`,
message: `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`,
...(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 mdnPath = getMdnUrl(replacement.mdnPath);
} else if (firstCompatible.type === 'native') {
const nodeVersion = getNodeMinVersion(firstCompatible.engines);
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}.`;
nodeVersion && !enginesNode ? ` Required Node >= ${nodeVersion}.` : '';
const urlStr = resolveUrl(firstCompatible.url);
result.messages.push({
severity: 'warning',
score: 0,
message: fullMessage,
message: `Module "${name}" can be replaced with native functionality.${requires} You can read more at ${urlStr}.`,
...(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}.`;
} else if (firstCompatible.type === 'documented') {
const urlStr = resolveUrl(firstCompatible.url);
result.messages.push({
severity: 'warning',
score: 0,
message: fullMessage,
message: `Module "${name}" can be replaced with a more performant alternative. See the list of available alternatives at ${urlStr}.`,
...(fixableBy && {fixableBy})
});
}
Expand Down
Loading
Loading