Skip to content

Commit 6c7ce23

Browse files
dreyfus9243081j
andauthored
feat(analyze): add shared runtime target resolution (#223)
* feat(analyze): add shared runtime target resolution * feat: integrate `enginematch` package for engine compatibility checks * fix(tests): update filesystem initialization to use `root` instead of `tempDir` in context creation * chore(deps): update `lockparse` dependency to version 0.5.2 * chore: upgrade enginematch * feat: remove new flags Greatly simplifies the logic by removing the two new flags and only inferring via enginematch. * chore: bump enginematch & add tests * chore: drop old type * chore: redundant dep * chore: revert test files --------- Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
1 parent 0646840 commit 6c7ce23

5 files changed

Lines changed: 236 additions & 59 deletions

File tree

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@
4848
"dependencies": {
4949
"@clack/prompts": "^1.4.0",
5050
"@e18e/web-features-codemods": "^0.2.0",
51-
"fast-wrap-ansi": "^0.2.0",
5251
"@publint/pack": "^0.1.4",
52+
"core-js-compat": "^3.48.0",
53+
"enginematch": "^0.1.3",
5354
"fast-wrap-ansi": "^0.2.0",
5455
"fdir": "^6.5.0",
5556
"gunshi": "^0.32.0",
@@ -59,7 +60,6 @@
5960
"obug": "^2.1.1",
6061
"package-manager-detector": "^1.6.0",
6162
"publint": "^0.3.21",
62-
"core-js-compat": "^3.48.0",
6363
"semver": "^7.8.0",
6464
"tinyglobby": "^0.2.16"
6565
},

src/analyze/replacements.ts

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,12 @@ import type {
44
EngineConstraint,
55
KnownUrl
66
} from 'module-replacements';
7+
import {type PackageJson, satisfies} from 'enginematch';
78
import type {ReportPluginResult, AnalysisContext} from '../types.js';
89
import {fixableReplacements} from '../commands/fixable-replacements.js';
910
import {getPackageJson} from '../utils/package-json.js';
1011
import {getManifestForCategories} from '../categories.js';
1112
import {resolve, dirname, basename} from 'node:path';
12-
import {
13-
satisfies as semverSatisfies,
14-
ltr as semverLessThan,
15-
minVersion,
16-
validRange
17-
} from 'semver';
1813
import {LocalFileSystem} from '../local-file-system.js';
1914

2015
/**
@@ -32,44 +27,23 @@ export function resolveUrl(url: KnownUrl): string {
3227
}
3328
}
3429

35-
function getNodeMinVersion(engines?: EngineConstraint[]): string | undefined {
30+
function getNodeJSMinVersion(engines?: EngineConstraint[]): string | undefined {
3631
return engines?.find((e) => e.engine === 'nodejs')?.minVersion;
3732
}
3833

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-
6134
function findFirstCompatibleReplacement(
6235
replacementIds: string[],
6336
defs: Record<string, ModuleReplacement>,
64-
enginesNode: string | undefined
37+
pkg: PackageJson,
38+
root: string
6539
): ModuleReplacement | undefined {
6640
for (const id of replacementIds) {
6741
const replacement = defs[id];
6842
if (!replacement) continue;
6943

70-
if (replacement.type === 'native' && enginesNode) {
71-
const nodeVersion = getNodeMinVersion(replacement.engines);
72-
if (nodeVersion && !isNodeEngineCompatible(nodeVersion, enginesNode)) {
44+
const reqs = replacement.engines;
45+
if (reqs?.length) {
46+
if (!satisfies(pkg, {requirements: reqs, cwd: root})) {
7347
continue;
7448
}
7549
}
@@ -157,7 +131,8 @@ export async function runReplacements(
157131
const firstCompatible = findFirstCompatibleReplacement(
158132
mapping.replacements,
159133
allReplacementDefs,
160-
enginesNode
134+
packageJson as PackageJson,
135+
context.root
161136
);
162137
if (!firstCompatible) {
163138
continue;
@@ -175,7 +150,7 @@ export async function runReplacements(
175150
message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`;
176151
break;
177152
case 'native': {
178-
const nodeVersion = getNodeMinVersion(firstCompatible.engines);
153+
const nodeVersion = getNodeJSMinVersion(firstCompatible.engines);
179154
const requires =
180155
nodeVersion && !enginesNode
181156
? ` Required Node >= ${nodeVersion}.`
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import {runReplacements} from '../../analyze/replacements.js';
5+
import {LocalFileSystem} from '../../local-file-system.js';
6+
import {createTempDir, cleanupTempDir} from '../utils.js';
7+
import type {AnalysisContext, PackageJsonLike} from '../../types.js';
8+
9+
const MANIFEST = {
10+
mappings: {
11+
'legacy-pkg': {
12+
type: 'module',
13+
moduleName: 'legacy-pkg',
14+
replacements: ['legacy-native']
15+
},
16+
'old-browser-pkg': {
17+
type: 'module',
18+
moduleName: 'old-browser-pkg',
19+
replacements: ['modern-browser-native']
20+
}
21+
},
22+
replacements: {
23+
'legacy-native': {
24+
id: 'legacy-native',
25+
type: 'simple',
26+
description: 'use the native equivalent',
27+
engines: [{engine: 'nodejs', minVersion: '20.0.0'}]
28+
},
29+
'modern-browser-native': {
30+
id: 'modern-browser-native',
31+
type: 'simple',
32+
description: 'use the native browser API',
33+
engines: [{engine: 'chrome', minVersion: '100.0.0'}]
34+
}
35+
}
36+
};
37+
38+
async function writeManifest(root: string): Promise<string> {
39+
const manifestPath = path.join(root, 'manifest.json');
40+
await fs.writeFile(manifestPath, JSON.stringify(MANIFEST));
41+
return manifestPath;
42+
}
43+
44+
async function setupContext(
45+
root: string,
46+
packageFile: PackageJsonLike,
47+
manifestPath: string
48+
): Promise<AnalysisContext> {
49+
await fs.writeFile(
50+
path.join(root, 'package.json'),
51+
JSON.stringify(packageFile)
52+
);
53+
return makeContext(root, packageFile, manifestPath);
54+
}
55+
56+
function makeContext(
57+
root: string,
58+
packageFile: PackageJsonLike,
59+
manifestPath: string
60+
): AnalysisContext {
61+
return {
62+
fs: new LocalFileSystem(root),
63+
root,
64+
messages: [],
65+
stats: {
66+
name: packageFile.name,
67+
version: packageFile.version,
68+
dependencyCount: {production: 0, development: 0},
69+
extraStats: []
70+
},
71+
lockfile: {
72+
type: 'npm',
73+
packages: [],
74+
root: {
75+
name: packageFile.name,
76+
version: packageFile.version,
77+
dependencies: [],
78+
devDependencies: [],
79+
optionalDependencies: [],
80+
peerDependencies: []
81+
}
82+
},
83+
packageFile,
84+
options: {manifest: [manifestPath]}
85+
};
86+
}
87+
88+
describe('runReplacements engine filtering', () => {
89+
let tempDir: string;
90+
let manifestPath: string;
91+
92+
beforeEach(async () => {
93+
tempDir = await createTempDir();
94+
manifestPath = await writeManifest(tempDir);
95+
});
96+
97+
afterEach(async () => {
98+
await cleanupTempDir(tempDir);
99+
});
100+
101+
it('emits a replacement when engines.node satisfies the requirement', async () => {
102+
const pkg: PackageJsonLike = {
103+
name: 'test-pkg',
104+
version: '1.0.0',
105+
dependencies: {'legacy-pkg': '1.0.0'},
106+
engines: {node: '>=20'}
107+
};
108+
const result = await runReplacements(
109+
await setupContext(tempDir, pkg, manifestPath)
110+
);
111+
112+
expect(result.messages).toHaveLength(1);
113+
expect(result.messages[0]?.message).toContain('legacy-pkg');
114+
});
115+
116+
it('filters out a replacement when engines.node does not satisfy the requirement', async () => {
117+
const pkg: PackageJsonLike = {
118+
name: 'test-pkg',
119+
version: '1.0.0',
120+
dependencies: {'legacy-pkg': '1.0.0'},
121+
engines: {node: '>=16'}
122+
};
123+
const result = await runReplacements(
124+
await setupContext(tempDir, pkg, manifestPath)
125+
);
126+
127+
expect(result.messages).toEqual([]);
128+
});
129+
130+
it('emits a replacement when no engines are declared (constraint trivially satisfied)', async () => {
131+
const pkg: PackageJsonLike = {
132+
name: 'test-pkg',
133+
version: '1.0.0',
134+
dependencies: {'legacy-pkg': '1.0.0'}
135+
};
136+
const result = await runReplacements(
137+
await setupContext(tempDir, pkg, manifestPath)
138+
);
139+
140+
expect(result.messages).toHaveLength(1);
141+
});
142+
143+
it('discovers .browserslistrc from cwd to filter a replacement', async () => {
144+
await fs.writeFile(
145+
path.join(tempDir, '.browserslistrc'),
146+
'chrome >= 110\n'
147+
);
148+
149+
const pkg: PackageJsonLike = {
150+
name: 'test-pkg',
151+
version: '1.0.0',
152+
dependencies: {'old-browser-pkg': '1.0.0'}
153+
};
154+
const result = await runReplacements(
155+
await setupContext(tempDir, pkg, manifestPath)
156+
);
157+
158+
expect(result.messages).toHaveLength(1);
159+
expect(result.messages[0]?.message).toContain('old-browser-pkg');
160+
});
161+
162+
it('filters via .browserslistrc when the resolved browser version is too low', async () => {
163+
await fs.writeFile(path.join(tempDir, '.browserslistrc'), 'chrome >= 90\n');
164+
165+
const pkg: PackageJsonLike = {
166+
name: 'test-pkg',
167+
version: '1.0.0',
168+
dependencies: {'old-browser-pkg': '1.0.0'}
169+
};
170+
const result = await runReplacements(
171+
await setupContext(tempDir, pkg, manifestPath)
172+
);
173+
174+
expect(result.messages).toEqual([]);
175+
});
176+
177+
it('uses package.json browserslist field to filter a replacement', async () => {
178+
const pkg = {
179+
name: 'test-pkg',
180+
version: '1.0.0',
181+
dependencies: {'old-browser-pkg': '1.0.0'},
182+
browserslist: ['chrome >= 90']
183+
} as PackageJsonLike;
184+
const result = await runReplacements(
185+
await setupContext(tempDir, pkg, manifestPath)
186+
);
187+
188+
expect(result.messages).toEqual([]);
189+
});
190+
});

0 commit comments

Comments
 (0)