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
198 changes: 190 additions & 8 deletions rules/no-unnecessary-polyfills.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,56 @@ const messages = {
'All polyfilled features imported from `{{coreJsModule}}` are available as built-ins. Use the built-ins instead.',
};

const additionalPolyfillPatterns = {
'es.promise.finally': '|(p-finally)',
'es.object.set-prototype-of': '|(setprototypeof)',
'es.string.code-point-at': '|(code-point-at)',
const additionalPolyfillModules = {
'es.promise.finally': ['p-finally'],
'es.object.set-prototype-of': ['setprototypeof'],
'es.string.code-point-at': ['code-point-at'],
};
const additionalPolyfillPatterns = Object.fromEntries(
Object.entries(additionalPolyfillModules).map(([feature, modules]) => [feature, `|(${modules.join('|')})`]),
);

const prefixes = '(mdn-polyfills/|polyfill-)';
const suffixes = '(-polyfill)';
const delimiter = String.raw`(\.|-|\.prototype\.|/)?`;
const moduleDelimiter = /[./-]/u;

const getFirstSegment = value => {
const [firstSegment = ''] = value.split(moduleDelimiter);
return firstSegment;
};

const stripPolyfillPrefix = value => {
if (value.startsWith('polyfill-')) {
return value.slice('polyfill-'.length);
}

if (value.startsWith('mdn-polyfills/')) {
return value.slice('mdn-polyfills/'.length);
}

return value;
};

function addPolyfillToken(tokens, value) {
if (!value) {
return;
}

const lowercaseValue = value.toLowerCase();
tokens.add(lowercaseValue);
tokens.add(getFirstSegment(lowercaseValue));

const camelCasedValue = camelCase(value).toLowerCase();
tokens.add(camelCasedValue);
tokens.add(getFirstSegment(camelCasedValue));
}

const polyfills = Object.keys(compatData).map(feature => {
let [ecmaVersion, constructorName, methodName = ''] = feature.split('.');
const [rawEcmaVersion, rawConstructorName, rawMethodName = ''] = feature.split('.');
let ecmaVersion = rawEcmaVersion;
let constructorName = rawConstructorName;
let methodName = rawMethodName;

if (ecmaVersion === 'es') {
ecmaVersion = String.raw`(es\d*)`;
Expand All @@ -49,8 +87,145 @@ const polyfills = Object.keys(compatData).map(feature => {
return {
feature,
pattern: new RegExp(patterns.join(''), 'i'),
tokens: (() => {
const tokens = new Set();

if (rawEcmaVersion === 'es') {
tokens.add('es');
} else {
addPolyfillToken(tokens, rawEcmaVersion);
}

addPolyfillToken(tokens, rawConstructorName);
addPolyfillToken(tokens, rawMethodName);

for (const module of additionalPolyfillModules[feature] || []) {
addPolyfillToken(tokens, module);
}

return tokens;
})(),
};
});
const polyfillsByToken = new Map();
const polyfillTokensByFirstCharacter = new Map();
const esConstructorTokens = new Set();

for (const polyfill of polyfills) {
const [ecmaVersion, constructorName] = polyfill.feature.split('.');
if (ecmaVersion === 'es') {
esConstructorTokens.add(constructorName.toLowerCase());
esConstructorTokens.add(camelCase(constructorName).toLowerCase());
}

for (const token of polyfill.tokens) {
if (!token) {
continue;
}

if (polyfillsByToken.has(token)) {
polyfillsByToken.get(token).push(polyfill);
} else {
polyfillsByToken.set(token, [polyfill]);
}

const firstCharacter = token[0];
if (polyfillTokensByFirstCharacter.has(firstCharacter)) {
polyfillTokensByFirstCharacter.get(firstCharacter).add(token);
} else {
polyfillTokensByFirstCharacter.set(firstCharacter, new Set([token]));
}
}
}

const hasEsConstructorPrefix = value => {
for (const token of esConstructorTokens) {
if (value.startsWith(token)) {
return true;
}
}

return false;
};

const isPotentialEsPrefix = importedModule => {
if (!importedModule.startsWith('es')) {
return false;
}

let constructorIndex = 2;
while (
constructorIndex < importedModule.length
&& importedModule[constructorIndex] >= '0'
&& importedModule[constructorIndex] <= '9'
) {
constructorIndex++;
}

if (importedModule.startsWith('.prototype.', constructorIndex)) {
constructorIndex += '.prototype.'.length;
} else if (['.', '-', '/'].includes(importedModule[constructorIndex])) {
constructorIndex++;
}

return hasEsConstructorPrefix(importedModule.slice(constructorIndex));
};

const getPolyfillCandidates = importedModule => {
const normalizedImportedModule = stripPolyfillPrefix(importedModule);
if (!normalizedImportedModule) {
return;
}

const firstCharacter = normalizedImportedModule[0];
const tokens = polyfillTokensByFirstCharacter.get(firstCharacter);
if (!tokens) {
return;
}

const candidates = new Set();
const firstSegment = getFirstSegment(normalizedImportedModule);
if (firstSegment === normalizedImportedModule) {
for (const token of tokens) {
if (token === 'es') {
if (!isPotentialEsPrefix(normalizedImportedModule)) {
continue;
}
} else if (!normalizedImportedModule.startsWith(token)) {
continue;
}

for (const polyfill of polyfillsByToken.get(token)) {
candidates.add(polyfill);
}
}
} else {
for (const token of tokens) {
if (
token === 'es'
|| !firstSegment.startsWith(token)
) {
continue;
}

for (const polyfill of polyfillsByToken.get(token)) {
candidates.add(polyfill);
}
}
}

if (isPotentialEsPrefix(normalizedImportedModule)) {
for (const polyfill of polyfillsByToken.get('es') || []) {
candidates.add(polyfill);
}
}

if (candidates.size === 0) {
return;
}

return [...candidates];
};

function getTargets(options, dirname) {
if (options?.targets) {
Expand Down Expand Up @@ -81,11 +256,13 @@ function create(context) {
return;
}

const unavailableFeatureSet = new Set(unavailableFeatures);

// When core-js graduates a feature from `esnext` to `es`, the entries list both (e.g. `['es.regexp.escape', 'esnext.regexp.escape']`),
// but `coreJsCompat` only includes the `es` version in its unavailable list, making the `esnext` version appear "available".
// To avoid false positives, treat `esnext.*` features as unavailable when their `es.*` counterpart is already in the list.
const checkFeatures = features => !features.every(feature =>
unavailableFeatures.includes(feature)
unavailableFeatureSet.has(feature)
|| (feature.startsWith('esnext.') && features.includes(feature.replace('esnext.', 'es.'))),
);

Expand Down Expand Up @@ -117,14 +294,19 @@ function create(context) {
},
};
}
} else if (!unavailableFeatures.includes(coreJsModuleFeatures[0])) {
} else if (!unavailableFeatureSet.has(coreJsModuleFeatures[0])) {
return {node, messageId: MESSAGE_ID_POLYFILL};
}

return;
}

const polyfill = polyfills.find(({pattern}) => pattern.test(importedModule));
const polyfillCandidates = getPolyfillCandidates(importedModule.toLowerCase());
if (!polyfillCandidates) {
return;
}

const polyfill = polyfillCandidates.find(({pattern}) => pattern.test(importedModule));
if (polyfill) {
const [, namespace, method = ''] = polyfill.feature.split('.');
const features = coreJsEntries[`core-js/full/${namespace}${method && '/'}${method}`];
Expand Down
20 changes: 20 additions & 0 deletions test/no-unnecessary-polyfills.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,26 @@ test({
options: [{targets: 'node >15'}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("promiseall-settled-polyfill")',
options: [{targets: {node: '20'}}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("es6-promise")',
options: [{targets: 'node >15'}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("es.prototype.array.find")',
options: [{targets: {node: '20'}}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("polyfill-es.prototype.array.find")',
options: [{targets: {node: '20'}}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("object-assign")',
options: [{targets: 'node 6'}],
Expand Down Expand Up @@ -167,6 +182,11 @@ test({
options: [{targets: 'node 4'}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("arrayevery-polyfill")',
options: [{targets: {node: '20'}}],
errors: [{message: 'Use built-in instead.'}],
},
{
code: 'require("mdn-polyfills/Array.prototype.findIndex")',
options: [{targets: 'node 4'}],
Expand Down
59 changes: 59 additions & 0 deletions test/unit/no-unnecessary-polyfills-performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import test from 'ava';
import path from 'node:path';
import url from 'node:url';
import {execFileSync} from 'node:child_process';

test('No unnecessary polyfills avoids scanning every pattern for unrelated imports', t => {
const cwd = path.dirname(path.dirname(path.dirname(url.fileURLToPath(import.meta.url))));
const script = `
import {ESLint} from 'eslint';
import plugin from './index.js';

const lintAndCountChecks = async moduleName => {
let testCount = 0;
const originalTest = RegExp.prototype.test;
RegExp.prototype.test = function (...arguments_) {
if (typeof this.source === 'string' && this.source.includes('mdn-polyfills') && this.source.includes('polyfill-')) {
testCount++;
}

return Reflect.apply(originalTest, this, arguments_);
};

const eslint = new ESLint({
overrideConfig: {
plugins: {unicorn: plugin},
languageOptions: {ecmaVersion: 'latest', sourceType: 'module'},
rules: {'unicorn/no-unnecessary-polyfills': ['error', {targets: {node: '20'}}]},
},
overrideConfigFile: true,
ignore: false,
});

try {
const [result] = await eslint.lintText(\`import value from "\${moduleName}";\`, {filePath: 'fixture.js'});
if (result.messages.length > 0) {
throw new Error('Unexpected lint errors');
}
} finally {
RegExp.prototype.test = originalTest;
}

return testCount;
};

const testCounts = {
normalImport: await lintAndCountChecks('eslint-package'),
polyfillPrefixImport: await lintAndCountChecks('polyfill-not-a-real-module'),
};

console.log(JSON.stringify(testCounts));
`;
const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], {cwd, encoding: 'utf8'});
const testCounts = JSON.parse(output.trim());

t.true(Number.isFinite(testCounts.normalImport), `Expected numeric count output, got ${output}.`);
t.true(Number.isFinite(testCounts.polyfillPrefixImport), `Expected numeric count output, got ${output}.`);
t.true(testCounts.normalImport < 10, `Expected fewer than 10 polyfill regex checks for normal import, got ${testCounts.normalImport}.`);
t.true(testCounts.polyfillPrefixImport < 20, `Expected fewer than 20 polyfill regex checks for polyfill-prefix import, got ${testCounts.polyfillPrefixImport}.`);
});
Loading