Skip to content

Commit 49ee97f

Browse files
dreyfus9243081j
andauthored
feat: migrate to module-replacements v3 (#180)
* feat: migrate to module-replacements v3 * format * update: snapshopts * fix: drop description from native replacement messages * refactor: prefer mapping url as canonical docs link * Update src/analyze/replacements.ts Co-authored-by: James Garbutt <43081j@users.noreply.github.com> * update: snapshots * cleanup * refactor(replacements): use type-specific messaging and append mapping URL per type * format * Apply suggestions from code review Co-authored-by: James Garbutt <43081j@users.noreply.github.com> * update: snaptshots * fix(replacements): simplify documented message, rely on mapping URL --------- Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
1 parent 33f68f3 commit 49ee97f

File tree

7 files changed

+243
-167
lines changed

7 files changed

+243
-167
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"fdir": "^6.5.0",
5353
"gunshi": "^0.29.2",
5454
"lockparse": "^0.5.0",
55-
"module-replacements": "^2.11.0",
55+
"module-replacements": "^3.0.0-beta.0",
5656
"module-replacements-codemods": "^1.2.0",
5757
"obug": "^2.1.1",
5858
"package-manager-detector": "^1.6.0",

scripts/generate-fixable-replacements.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,38 @@ import {join, dirname} from 'node:path';
33
import {fileURLToPath} from 'node:url';
44
import {all} from 'module-replacements';
55
import {codemods} from 'module-replacements-codemods';
6-
import {fixableReplacements} from '../lib/commands/fixable-replacements.js';
76

87
const __dirname = dirname(fileURLToPath(import.meta.url));
98

10-
async function generateFixableReplacements() {
11-
const existingReplacements = new Map(
12-
fixableReplacements.map((r) => [r.from, r])
13-
);
9+
function getReplacementTarget(moduleName: string): string {
10+
const mapping = all.mappings[moduleName];
11+
if (!mapping?.replacements?.length) return moduleName;
12+
13+
const firstId = mapping.replacements[0]!;
14+
const replacement = all.replacements[firstId];
15+
if (!replacement) return firstId;
16+
17+
if (replacement.type === 'documented' && replacement.replacementModule) {
18+
return replacement.replacementModule;
19+
}
20+
21+
return replacement.id;
22+
}
1423

24+
async function generateFixableReplacements() {
1525
let newCode = `import type { Replacement } from '../types.js';\n`;
1626
newCode += `import { codemods } from 'module-replacements-codemods';\n\n`;
1727
newCode += `export const fixableReplacements: Replacement[] = [\n`;
1828

1929
let count = 0;
20-
for (const replacement of all.moduleReplacements) {
21-
if (replacement.moduleName in codemods) {
22-
const existing = existingReplacements.get(replacement.moduleName);
23-
const to = existing?.to ?? 'TODO';
30+
for (const moduleName of Object.keys(all.mappings)) {
31+
if (moduleName in codemods) {
32+
const to = getReplacementTarget(moduleName);
2433

2534
newCode += ` {\n`;
26-
newCode += ` from: '${replacement.moduleName}',\n`;
35+
newCode += ` from: '${moduleName}',\n`;
2736
newCode += ` to: '${to}',\n`;
28-
newCode += ` factory: codemods['${replacement.moduleName}']\n`;
37+
newCode += ` factory: codemods['${moduleName}']\n`;
2938
newCode += ` },\n`;
3039
count++;
3140
}

src/analyze/replacements.ts

Lines changed: 128 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import * as replacements from 'module-replacements';
2-
import type {ManifestModule, ModuleReplacement} from 'module-replacements';
2+
import type {
3+
ManifestModule,
4+
ModuleReplacement,
5+
EngineConstraint,
6+
KnownUrl
7+
} from 'module-replacements';
38
import type {ReportPluginResult, AnalysisContext} from '../types.js';
49
import {fixableReplacements} from '../commands/fixable-replacements.js';
510
import {getPackageJson} from '../utils/package-json.js';
@@ -13,27 +18,74 @@ import {
1318
import {LocalFileSystem} from '../local-file-system.js';
1419

1520
/**
16-
* Generates a standard URL to the docs of a given rule
17-
* @param {string} name Rule name
18-
* @return {string}
21+
* Resolves a v3 KnownUrl to a full URL string.
1922
*/
20-
export function getDocsUrl(name: string): string {
21-
return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${name}.md`;
23+
export function resolveUrl(url: KnownUrl): string {
24+
if (typeof url === 'string') return url;
25+
switch (url.type) {
26+
case 'mdn':
27+
return `https://developer.mozilla.org/en-US/docs/${url.id}`;
28+
case 'node':
29+
return `https://nodejs.org/docs/latest/${url.id}`;
30+
case 'e18e':
31+
return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${url.id}.md`;
32+
}
2233
}
2334

24-
/**
25-
* Generates a URL for the given path on MDN
26-
* @param {string} path Docs path
27-
* @return {string}
28-
*/
29-
export function getMdnUrl(path: string): string {
30-
return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${path}`;
35+
function getNodeMinVersion(engines?: EngineConstraint[]): string | undefined {
36+
return engines?.find((e) => e.engine === 'nodejs')?.minVersion;
37+
}
38+
39+
function isNodeEngineCompatible(
40+
requiredNode: string,
41+
enginesNode: string
42+
): boolean {
43+
const requiredRange = validRange(requiredNode);
44+
const engineRange = validRange(enginesNode);
45+
46+
if (!requiredRange || !engineRange) {
47+
return true;
48+
}
49+
50+
const requiredMin = minVersion(requiredRange);
51+
if (!requiredMin) {
52+
return true;
53+
}
54+
55+
return (
56+
semverLessThan(requiredMin.version, engineRange) ||
57+
semverSatisfies(requiredMin.version, engineRange)
58+
);
59+
}
60+
61+
function findFirstCompatibleReplacement(
62+
replacementIds: string[],
63+
defs: Record<string, ModuleReplacement>,
64+
enginesNode: string | undefined
65+
): ModuleReplacement | undefined {
66+
for (const id of replacementIds) {
67+
const replacement = defs[id];
68+
if (!replacement) continue;
69+
70+
if (replacement.type === 'native' && enginesNode) {
71+
const nodeVersion = getNodeMinVersion(replacement.engines);
72+
if (nodeVersion && !isNodeEngineCompatible(nodeVersion, enginesNode)) {
73+
continue;
74+
}
75+
}
76+
77+
return replacement;
78+
}
79+
return undefined;
3180
}
3281

3382
async function loadCustomManifests(
3483
manifestPaths: string[]
35-
): Promise<ModuleReplacement[]> {
36-
const customReplacements: ModuleReplacement[] = [];
84+
): Promise<ManifestModule> {
85+
const result: ManifestModule = {
86+
mappings: {},
87+
replacements: {}
88+
};
3789

3890
for (const manifestPath of manifestPaths) {
3991
try {
@@ -46,11 +98,11 @@ async function loadCustomManifests(
4698
);
4799
const manifest: ManifestModule = JSON.parse(manifestContent);
48100

49-
if (
50-
manifest.moduleReplacements &&
51-
Array.isArray(manifest.moduleReplacements)
52-
) {
53-
customReplacements.push(...manifest.moduleReplacements);
101+
if (manifest.mappings) {
102+
Object.assign(result.mappings, manifest.mappings);
103+
}
104+
if (manifest.replacements) {
105+
Object.assign(result.replacements, manifest.replacements);
54106
}
55107
} catch (error) {
56108
console.warn(
@@ -59,29 +111,7 @@ async function loadCustomManifests(
59111
}
60112
}
61113

62-
return customReplacements;
63-
}
64-
65-
function isNodeEngineCompatible(
66-
requiredNode: string,
67-
enginesNode: string
68-
): boolean {
69-
const requiredRange = validRange(requiredNode);
70-
const engineRange = validRange(enginesNode);
71-
72-
if (!requiredRange || !engineRange) {
73-
return true;
74-
}
75-
76-
const requiredMin = minVersion(requiredRange);
77-
if (!requiredMin) {
78-
return true;
79-
}
80-
81-
return (
82-
semverLessThan(requiredMin.version, engineRange) ||
83-
semverSatisfies(requiredMin.version, engineRange)
84-
);
114+
return result;
85115
}
86116

87117
export async function runReplacements(
@@ -94,89 +124,77 @@ export async function runReplacements(
94124
const packageJson = await getPackageJson(context.fs);
95125

96126
if (!packageJson || !packageJson.dependencies) {
97-
// No dependencies
98127
return result;
99128
}
100129

101-
// Load custom manifests
102-
const customReplacements = context.options?.manifest
130+
const customManifest = context.options?.manifest
103131
? await loadCustomManifests(context.options.manifest)
104-
: [];
132+
: {mappings: {}, replacements: {}};
105133

106-
// Combine custom and built-in replacements
107-
const allReplacements = [
108-
...customReplacements,
109-
...replacements.all.moduleReplacements
110-
];
134+
// Custom mappings take precedence over built-in
135+
const allMappings = {
136+
...replacements.all.mappings,
137+
...customManifest.mappings
138+
};
139+
const allReplacementDefs: Record<string, ModuleReplacement> = {
140+
...replacements.all.replacements,
141+
...customManifest.replacements
142+
};
111143

112144
const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from));
145+
const enginesNode = packageJson.engines?.node;
113146

114147
for (const name of Object.keys(packageJson.dependencies)) {
115-
// Find replacement (custom replacements take precedence due to order)
116-
const replacement = allReplacements.find(
117-
(replacement) => replacement.moduleName === name
118-
);
148+
const mapping = allMappings[name];
149+
if (!mapping?.replacements?.length) {
150+
continue;
151+
}
119152

120-
if (!replacement) {
153+
const firstCompatible = findFirstCompatibleReplacement(
154+
mapping.replacements,
155+
allReplacementDefs,
156+
enginesNode
157+
);
158+
if (!firstCompatible) {
121159
continue;
122160
}
123161

124162
const fixableBy = fixableByMigrate.has(name) ? 'migrate' : undefined;
125-
126-
// Handle each replacement type using the same logic for both custom and built-in
127-
if (replacement.type === 'none') {
128-
result.messages.push({
129-
severity: 'warning',
130-
score: 0,
131-
message: `Module "${name}" can be removed, and native functionality used instead`,
132-
...(fixableBy && {fixableBy})
133-
});
134-
} else if (replacement.type === 'simple') {
135-
result.messages.push({
136-
severity: 'warning',
137-
score: 0,
138-
message: `Module "${name}" can be replaced. ${replacement.replacement}.`,
139-
...(fixableBy && {fixableBy})
140-
});
141-
} else if (replacement.type === 'native') {
142-
const enginesNode = packageJson.engines?.node;
143-
let supported = true;
144-
145-
if (replacement.nodeVersion && enginesNode) {
146-
supported = isNodeEngineCompatible(
147-
replacement.nodeVersion,
148-
enginesNode
149-
);
150-
}
151-
152-
if (!supported) {
153-
continue;
163+
const mappingUrl = mapping.url ? resolveUrl(mapping.url) : undefined;
164+
165+
let message: string;
166+
switch (firstCompatible.type) {
167+
case 'removal':
168+
message = `Module "${name}" can be removed, and native functionality used instead`;
169+
break;
170+
case 'simple':
171+
message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`;
172+
break;
173+
case 'native': {
174+
const nodeVersion = getNodeMinVersion(firstCompatible.engines);
175+
const requires =
176+
nodeVersion && !enginesNode
177+
? ` Required Node >= ${nodeVersion}.`
178+
: '';
179+
const urlStr = resolveUrl(firstCompatible.url);
180+
message = `Module "${name}" can be replaced with native functionality.${requires} You can read more at ${urlStr}.`;
181+
break;
154182
}
155-
156-
const mdnPath = getMdnUrl(replacement.mdnPath);
157-
const requires =
158-
replacement.nodeVersion && !enginesNode
159-
? ` Required Node >= ${replacement.nodeVersion}.`
160-
: '';
161-
const message = `Module "${name}" can be replaced with native functionality. Use "${replacement.replacement}" instead.${requires}`;
162-
const fullMessage = `${message} You can read more at ${mdnPath}.`;
163-
result.messages.push({
164-
severity: 'warning',
165-
score: 0,
166-
message: fullMessage,
167-
...(fixableBy && {fixableBy})
168-
});
169-
} else if (replacement.type === 'documented') {
170-
const docUrl = getDocsUrl(replacement.docPath);
171-
const message = `Module "${name}" can be replaced with a more performant alternative.`;
172-
const fullMessage = `${message} See the list of available alternatives at ${docUrl}.`;
173-
result.messages.push({
174-
severity: 'warning',
175-
score: 0,
176-
message: fullMessage,
177-
...(fixableBy && {fixableBy})
178-
});
183+
case 'documented':
184+
message = `Module "${name}" can be replaced with a more performant alternative.`;
185+
break;
186+
default:
187+
message = `Module "${name}" can be replaced with a more performant alternative.`;
188+
}
189+
if (mappingUrl) {
190+
message += ` See more at ${mappingUrl}.`;
179191
}
192+
result.messages.push({
193+
severity: 'warning',
194+
score: 0,
195+
message,
196+
...(fixableBy && {fixableBy})
197+
});
180198
}
181199

182200
return result;

0 commit comments

Comments
 (0)