diff --git a/configs/recommended.js b/configs/recommended.js index 6d7182f2c9..40cbd87530 100644 --- a/configs/recommended.js +++ b/configs/recommended.js @@ -51,6 +51,7 @@ module.exports = { 'unicorn/no-static-only-class': 'error', 'unicorn/no-thenable': 'error', 'unicorn/no-this-assignment': 'error', + 'unicorn/no-unnecessary-polyfills': 'error', 'unicorn/no-unreadable-array-destructuring': 'error', 'unicorn/no-unsafe-regex': 'off', 'unicorn/no-unused-properties': 'off', diff --git a/docs/rules/no-unnecessary-polyfills.md b/docs/rules/no-unnecessary-polyfills.md new file mode 100644 index 0000000000..81bb646a26 --- /dev/null +++ b/docs/rules/no-unnecessary-polyfills.md @@ -0,0 +1,60 @@ +# Enforce the use of built-in methods instead of unnecessary polyfills + + + +✅ *This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.* + + +This rules helps to use existing methods instead of using extra polyfills. + +## Fail + +package.json + +```json +{ + "engines": { + "node": ">=8" + } +} +``` + +```js +const assign = require('object-assign'); +``` + +## Pass + +package.json + +```json +{ + "engines": { + "node": "4" + } +} +``` + +```js +const assign = require('object-assign'); // passes as Object.assign is not supported +``` + +## Options + +Type: `object` + +### nodeVersion + +Type: `string` + +By default, the target version of Node.js is taken from the `package.json`, in the `engines.node` field. If you want to override this, you can set the `nodeVersion` option. + +**Note:** The SemVer syntax is fully supported in both the `package.json` and this option. + +```js +"unicorn/no-unnecessary-polyfills": ["error", { "nodeVersion": ">=12" }] +``` + +```js +"unicorn/no-unnecessary-polyfills": ["error", { "nodeVersion": "14.1.0" }] +``` diff --git a/package.json b/package.json index bdfee944ed..da1d88b81f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@babel/helper-validator-identifier": "^7.15.7", "ci-info": "^3.3.0", "clean-regexp": "^1.0.0", + "core-js-compat": "^3.21.0", "eslint-utils": "^3.0.0", "esquery": "^1.4.0", "indent-string": "^4.0.0", diff --git a/readme.md b/readme.md index 75ca706550..9942a8b6b0 100644 --- a/readme.md +++ b/readme.md @@ -92,6 +92,7 @@ Each rule has emojis denoting: | [no-static-only-class](docs/rules/no-static-only-class.md) | Forbid classes that only have static members. | ✅ | 🔧 | | | [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. | ✅ | | | | [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. | ✅ | | | +| [no-unnecessary-polyfills](docs/rules/no-unnecessary-polyfills.md) | Enforce the use of built-in methods instead of unnecessary polyfills. | ✅ | | | | [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) | Disallow unreadable array destructuring. | ✅ | 🔧 | | | [no-unsafe-regex](docs/rules/no-unsafe-regex.md) | Disallow unsafe regular expressions. | | | | | [no-unused-properties](docs/rules/no-unused-properties.md) | Disallow unused object properties. | | | | diff --git a/rules/no-unnecessary-polyfills.js b/rules/no-unnecessary-polyfills.js new file mode 100644 index 0000000000..cf958b5fd9 --- /dev/null +++ b/rules/no-unnecessary-polyfills.js @@ -0,0 +1,167 @@ +'use strict'; +const semver = require('semver'); +const readPkgUp = require('read-pkg-up'); +const {data: compatData, entries: coreJsEntries} = require('core-js-compat'); +const {camelCase, upperFirst} = require('lodash'); + +const MESSAGE_ID_POLYFILL = 'unnecessaryPolyfill'; +const MESSAGE_ID_CORE_JS = 'unnecessaryCoreJsModule'; +const messages = { + [MESSAGE_ID_POLYFILL]: 'Use the built-in `{{featureName}}`.', + [MESSAGE_ID_CORE_JS]: 'All polyfilled features imported from `{{coreJsModule}}` are disponible as built-ins. Use the built-ins instead.', +}; + +function getNodeEngineVersion(cwd) { + const result = readPkgUp.sync({cwd}); + return result && result.pkg && result.pkg.engines && result.pkg.engines.node; +} + +// Hard-coded compat data +compatData['node.util.promisify'] = {node: '8'}; + +const constructorCaseExceptions = { + regexp: 'RegExp', + util: 'util', +}; +function constructorCase(name) { + return constructorCaseExceptions[name] || upperFirst(camelCase(name)); +} + +const additionalPolyfillPatterns = { + 'es.promise.finally': '|(p-finally)', + 'es.object.set-prototype-of': '|(setprototypeof)', + 'es.string.code-point-at': '|(code-point-at)', +}; + +const prefixes = '(mdn-polyfills|polyfill-)'; +const suffixes = '(-polyfill)'; +const delimiter = '(\\.|-|\\.prototype\\.|/)?'; + +const polyfills = Object.keys(compatData).map(feature => { + let [ecmaVersion, constructorName, methodName = ''] = feature.split('.'); + + if (ecmaVersion === 'es') { + ecmaVersion = `(${ecmaVersion}\\d*)`; + } + + constructorName = `(${constructorName}|${camelCase(constructorName)})`; + if (methodName) { + methodName = `(${methodName}|${camelCase(methodName)})`; + } + + const methodOrConstructor = methodName || constructorName; + + return { + feature, + pattern: new RegExp( + `^((${prefixes}?` + + `(${ecmaVersion}${delimiter}${constructorName}${delimiter}${methodName}|` // Ex: es6-array-copy-within + + `${constructorName}${delimiter}${methodName}|` // Ex: array-copy-within + + `${ecmaVersion}${delimiter}${constructorName})` // Ex: es6-array + + `${suffixes}?)|` + + `(${prefixes}${methodOrConstructor}|${methodOrConstructor}${suffixes})` // Ex: polyfill-copy-within / polyfill-promise + + `${additionalPolyfillPatterns[feature] || ''})$`, + 'i', + ), + }; +}); + +function checkVersion(nodeVersion, featureVersion) { + const supportedNodeVersion = semver.coerce(featureVersion); + const validTargetVersion = semver.minVersion(nodeVersion); + return validTargetVersion + ? semver.lte(supportedNodeVersion, validTargetVersion) + : semver.ltr(supportedNodeVersion, nodeVersion); +} + +function report(context, node, feature) { + let [ecmaVersion, namespace, method = ''] = feature.split('.'); + if (namespace === 'typed-array' && method.endsWith('-array')) { + namespace = method; + method = ''; + } + + const delimiter = method && (ecmaVersion === 'node' ? '.' : '#'); + + context.report({ + node, + messageId: MESSAGE_ID_POLYFILL, + data: { + featureName: `${constructorCase(namespace)}${delimiter}${camelCase(method)}`, + }, + }); +} + +function processRule(context, node, moduleName, nodeVersion) { + if (!moduleName || typeof moduleName !== 'string') { + return; + } + + const importedModule = moduleName.replace(/\.[^/.]+$/, ''); + const coreJsModuleFeatures = coreJsEntries[importedModule.replace('core-js-pure', 'core-js')]; + + if (coreJsModuleFeatures) { + if (coreJsModuleFeatures.length === 1) { + if (checkVersion(nodeVersion, compatData[coreJsModuleFeatures[0]].node)) { + report(context, node, coreJsModuleFeatures[0]); + } + } else if (coreJsModuleFeatures.every(feature => compatData[feature].node && checkVersion(nodeVersion, compatData[feature].node))) { + context.report({ + node, + messageId: MESSAGE_ID_CORE_JS, + data: { + coreJsModule: moduleName, + }, + }); + } + return; + } + + const polyfill = polyfills.find(({pattern}) => pattern.test(moduleName)); + if (polyfill && checkVersion(nodeVersion, compatData[polyfill.feature].node)) { + report(context, node, polyfill.feature); + } +} + +function create(context) { + const options = context.options[0]; + const nodeVersion = (options && options.nodeVersion) || getNodeEngineVersion(context.getFilename()); + + if (!nodeVersion) { + return {}; + } + + return { + 'CallExpression[callee.name="require"]'(node) { + processRule(context, node, node.arguments[0].value, nodeVersion); + }, + 'ImportDeclaration, ImportExpression'(node) { + processRule(context, node, node.source.value, nodeVersion); + }, + }; +} + +const schema = [ + { + type: 'object', + additionalProperties: false, + properties: { + nodeVersion: { + type: 'string', + }, + }, + }, +]; + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce the use of built-in methods instead of unnecessary polyfills.', + }, + schema, + messages, + }, +}; diff --git a/test/no-unnecessary-polyfills.mjs b/test/no-unnecessary-polyfills.mjs new file mode 100644 index 0000000000..a3700fce70 --- /dev/null +++ b/test/no-unnecessary-polyfills.mjs @@ -0,0 +1,161 @@ +import {getTester} from './utils/test.mjs'; + +const {test} = getTester(import.meta); + +test({ + valid: [ + { + code: 'require("object-assign")', + options: [{nodeVersion: '0.1.0'}], + }, + { + code: 'require("this-is-not-a-polyfill")', + options: [{nodeVersion: '0.1.0'}], + }, + { + code: 'import assign from "object-assign"', + options: [{nodeVersion: '0.1.0'}], + }, + { + code: 'import("object-assign")', + options: [{nodeVersion: '0.1.0'}], + }, + { + code: 'require("object-assign")', + options: [{nodeVersion: '<4'}], + }, + { + code: 'require("object-assign")', + options: [{nodeVersion: '>3'}], + }, + { + code: 'require("util-promisify")', + options: [{nodeVersion: '4'}], + }, + ], + invalid: [ + { + code: 'require("setprototypeof")', + options: [{nodeVersion: '>4'}], + errors: [{message: 'Use the built-in `Object#setPrototypeOf`.'}], + }, + { + code: 'require("core-js/features/array/last-index-of")', + options: [{nodeVersion: '>6.5'}], + errors: [{message: 'Use the built-in `Array#lastIndexOf`.'}], + }, + { + code: 'require("core-js-pure/features/array/from")', + options: [{nodeVersion: '>7'}], + errors: [{message: 'All polyfilled features imported from `core-js-pure/features/array/from` are disponible as built-ins. Use the built-ins instead.'}], + }, + { + code: 'require("core-js/features/array/from")', + options: [{nodeVersion: '>7'}], + errors: [{message: 'All polyfilled features imported from `core-js/features/array/from` are disponible as built-ins. Use the built-ins instead.'}], + }, + // The feature esnext.typed-array.from-async polyfilled in this modules has not compat data + // { + // code: 'require("core-js/features/typed-array")', + // options: [{nodeVersion: '>16'}], + // errors: [{message: 'All polyfilled features imported from `core-js/features/typed-array` are disponible as built-ins. Use the built-ins instead.'}], + // }, + { + code: 'require("es6-symbol")', + options: [{nodeVersion: '>7'}], + errors: [{message: 'Use the built-in `Symbol`.'}], + }, + { + code: 'require("code-point-at")', + options: [{nodeVersion: '>4'}], + errors: [{message: 'Use the built-in `String#codePointAt`.'}], + }, + { + code: 'require("util.promisify")', + options: [{nodeVersion: '12'}], + errors: [{message: 'Use the built-in `util.promisify`.'}], + }, + { + code: 'require("object.getownpropertydescriptors")', + options: [{nodeVersion: '>8'}], + errors: [{message: 'Use the built-in `Object#getOwnPropertyDescriptors`.'}], + }, + { + code: 'require("string.prototype.padstart")', + options: [{nodeVersion: '>8'}], + errors: [{message: 'Use the built-in `String#padStart`.'}], + + }, + { + code: 'require("p-finally")', + options: [{nodeVersion: '>10'}], + errors: [{message: 'Use the built-in `Promise#finally`.'}], + + }, + { + code: 'require("promise-polyfill")', + options: [{nodeVersion: '>10.4'}], + errors: [{message: 'Use the built-in `Promise`.'}], + }, + { + code: 'require("es6-promise")', + options: [{nodeVersion: '>10.4'}], + errors: [{message: 'Use the built-in `Promise`.'}], + }, + { + code: 'require("object-assign")', + options: [{nodeVersion: '6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'import assign from "object-assign"', + options: [{nodeVersion: '6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("object-assign")', + options: [{nodeVersion: '>6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("object-assign")', + options: [{nodeVersion: '8'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("array-from")', + options: [{nodeVersion: '>7'}], + errors: [{message: 'Use the built-in `Array#from`.'}], + }, + { + code: 'require("array.prototype.every")', + options: [{nodeVersion: '~4.0.0'}], + errors: [{message: 'Use the built-in `Array#every`.'}], + }, + { + code: 'require("array-find-index")', + options: [{nodeVersion: '>4.0.0'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{nodeVersion: '>4'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{nodeVersion: '>=4 <5.2 || >6.0.0'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{nodeVersion: '4'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("weakmap-polyfill")', + options: [{nodeVersion: '12'}], + errors: [{message: 'Use the built-in `WeakMap`.'}], + }, + ], +});