Skip to content
Merged
48 changes: 31 additions & 17 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@
"dependencies": {
"@clack/prompts": "^1.4.0",
"@e18e/web-features-codemods": "^0.2.0",
"fast-wrap-ansi": "^0.2.0",
"@publint/pack": "^0.1.4",
"core-js-compat": "^3.48.0",
"enginematch": "^0.1.3",
"fast-wrap-ansi": "^0.2.0",
"fdir": "^6.5.0",
"gunshi": "^0.32.0",
Expand All @@ -59,7 +60,6 @@
"obug": "^2.1.1",
"package-manager-detector": "^1.6.0",
"publint": "^0.3.21",
"core-js-compat": "^3.48.0",
"semver": "^7.8.0",
"tinyglobby": "^0.2.16"
},
Expand Down
45 changes: 10 additions & 35 deletions src/analyze/replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,12 @@ import type {
EngineConstraint,
KnownUrl
} from 'module-replacements';
import {type PackageJson, satisfies} from 'enginematch';
import type {ReportPluginResult, AnalysisContext} from '../types.js';
import {fixableReplacements} from '../commands/fixable-replacements.js';
import {getPackageJson} from '../utils/package-json.js';
import {getManifestForCategories} from '../categories.js';
import {resolve, dirname, basename} from 'node:path';
import {
satisfies as semverSatisfies,
ltr as semverLessThan,
minVersion,
validRange
} from 'semver';
import {LocalFileSystem} from '../local-file-system.js';

/**
Expand All @@ -32,44 +27,23 @@ export function resolveUrl(url: KnownUrl): string {
}
}

function getNodeMinVersion(engines?: EngineConstraint[]): string | undefined {
function getNodeJSMinVersion(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
pkg: PackageJson,
root: string
): 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)) {
const reqs = replacement.engines;
if (reqs?.length) {
if (!satisfies(pkg, {requirements: reqs, cwd: root})) {
continue;
}
}
Expand Down Expand Up @@ -157,7 +131,8 @@ export async function runReplacements(
const firstCompatible = findFirstCompatibleReplacement(
mapping.replacements,
allReplacementDefs,
enginesNode
packageJson as PackageJson,
context.root
);
if (!firstCompatible) {
continue;
Expand All @@ -175,7 +150,7 @@ export async function runReplacements(
message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`;
break;
case 'native': {
const nodeVersion = getNodeMinVersion(firstCompatible.engines);
const nodeVersion = getNodeJSMinVersion(firstCompatible.engines);
const requires =
nodeVersion && !enginesNode
? ` Required Node >= ${nodeVersion}.`
Expand Down
190 changes: 190 additions & 0 deletions src/test/analyze/replacements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import {runReplacements} from '../../analyze/replacements.js';
import {LocalFileSystem} from '../../local-file-system.js';
import {createTempDir, cleanupTempDir} from '../utils.js';
import type {AnalysisContext, PackageJsonLike} from '../../types.js';

const MANIFEST = {
mappings: {
'legacy-pkg': {
type: 'module',
moduleName: 'legacy-pkg',
replacements: ['legacy-native']
},
'old-browser-pkg': {
type: 'module',
moduleName: 'old-browser-pkg',
replacements: ['modern-browser-native']
}
},
replacements: {
'legacy-native': {
id: 'legacy-native',
type: 'simple',
description: 'use the native equivalent',
engines: [{engine: 'nodejs', minVersion: '20.0.0'}]
},
'modern-browser-native': {
id: 'modern-browser-native',
type: 'simple',
description: 'use the native browser API',
engines: [{engine: 'chrome', minVersion: '100.0.0'}]
}
}
};

async function writeManifest(root: string): Promise<string> {
const manifestPath = path.join(root, 'manifest.json');
await fs.writeFile(manifestPath, JSON.stringify(MANIFEST));
return manifestPath;
}

async function setupContext(
root: string,
packageFile: PackageJsonLike,
manifestPath: string
): Promise<AnalysisContext> {
await fs.writeFile(
path.join(root, 'package.json'),
JSON.stringify(packageFile)
);
return makeContext(root, packageFile, manifestPath);
}

function makeContext(
root: string,
packageFile: PackageJsonLike,
manifestPath: string
): AnalysisContext {
return {
fs: new LocalFileSystem(root),
root,
messages: [],
stats: {
name: packageFile.name,
version: packageFile.version,
dependencyCount: {production: 0, development: 0},
extraStats: []
},
lockfile: {
type: 'npm',
packages: [],
root: {
name: packageFile.name,
version: packageFile.version,
dependencies: [],
devDependencies: [],
optionalDependencies: [],
peerDependencies: []
}
},
packageFile,
options: {manifest: [manifestPath]}
};
}

describe('runReplacements engine filtering', () => {
let tempDir: string;
let manifestPath: string;

beforeEach(async () => {
tempDir = await createTempDir();
manifestPath = await writeManifest(tempDir);
});

afterEach(async () => {
await cleanupTempDir(tempDir);
});

it('emits a replacement when engines.node satisfies the requirement', async () => {
const pkg: PackageJsonLike = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'legacy-pkg': '1.0.0'},
engines: {node: '>=20'}
};
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.message).toContain('legacy-pkg');
});

it('filters out a replacement when engines.node does not satisfy the requirement', async () => {
const pkg: PackageJsonLike = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'legacy-pkg': '1.0.0'},
engines: {node: '>=16'}
};
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toEqual([]);
});

it('emits a replacement when no engines are declared (constraint trivially satisfied)', async () => {
const pkg: PackageJsonLike = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'legacy-pkg': '1.0.0'}
};
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toHaveLength(1);
});

it('discovers .browserslistrc from cwd to filter a replacement', async () => {
await fs.writeFile(
path.join(tempDir, '.browserslistrc'),
'chrome >= 110\n'
);

const pkg: PackageJsonLike = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'old-browser-pkg': '1.0.0'}
};
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.message).toContain('old-browser-pkg');
});

it('filters via .browserslistrc when the resolved browser version is too low', async () => {
await fs.writeFile(path.join(tempDir, '.browserslistrc'), 'chrome >= 90\n');

const pkg: PackageJsonLike = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'old-browser-pkg': '1.0.0'}
};
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toEqual([]);
});

it('uses package.json browserslist field to filter a replacement', async () => {
const pkg = {
name: 'test-pkg',
version: '1.0.0',
dependencies: {'old-browser-pkg': '1.0.0'},
browserslist: ['chrome >= 90']
} as PackageJsonLike;
const result = await runReplacements(
await setupContext(tempDir, pkg, manifestPath)
);

expect(result.messages).toEqual([]);
});
});
Loading
Loading