From 88d1c377cb6ba3aa4fe744e130bbb81117dbecb3 Mon Sep 17 00:00:00 2001 From: Yusuke Iinuma Date: Mon, 19 Feb 2024 10:55:27 +0900 Subject: [PATCH] feat: Add n/prefer-node-protocol rule (#183) * feat: add `n/prefer-node-protocol` rule * feat: support `require` function * docs: add `export` examples * feat: enable or disable this rule by supported Node.js version * refactor: use `visit-require` and `visit-import` * fix: avoid type error by non-string types * refactor: use `moduleStyle` for simplicity * chore: update to false for avoiding a breaking change --- README.md | 1 + docs/rules/prefer-node-protocol.md | 73 ++++++++ lib/index.js | 1 + lib/rules/prefer-node-protocol.js | 150 ++++++++++++++++ lib/util/strip-import-path-params.js | 2 +- tests/lib/rules/prefer-node-protocol.js | 221 ++++++++++++++++++++++++ 6 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 docs/rules/prefer-node-protocol.md create mode 100644 lib/rules/prefer-node-protocol.js create mode 100644 tests/lib/rules/prefer-node-protocol.js diff --git a/README.md b/README.md index 60b4b531..230aef34 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable | [prefer-global/text-encoder](docs/rules/prefer-global/text-encoder.md) | enforce either `TextEncoder` or `require("util").TextEncoder` | | | | | [prefer-global/url](docs/rules/prefer-global/url.md) | enforce either `URL` or `require("url").URL` | | | | | [prefer-global/url-search-params](docs/rules/prefer-global/url-search-params.md) | enforce either `URLSearchParams` or `require("url").URLSearchParams` | | | | +| [prefer-node-protocol](docs/rules/prefer-node-protocol.md) | enforce using the `node:` protocol when importing Node.js builtin modules. | | 🔧 | | | [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | | | [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | | | [process-exit-as-throw](docs/rules/process-exit-as-throw.md) | require that `process.exit()` expressions use the same code path as `throw` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | | diff --git a/docs/rules/prefer-node-protocol.md b/docs/rules/prefer-node-protocol.md new file mode 100644 index 00000000..5256e449 --- /dev/null +++ b/docs/rules/prefer-node-protocol.md @@ -0,0 +1,73 @@ +# Enforce using the `node:` protocol when importing Node.js builtin modules (`n/prefer-node-protocol`) + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Older built-in Node modules such as fs now can be imported via either their name or `node:` + their name: + +```js +import fs from "fs" +import fs from "node:fs" +``` + +The prefixed versions are nice because they can't be overridden by user modules and are similarly formatted to prefix-only modules such as node:test. + +Note that Node.js support for this feature began in: + +> v16.0.0, v14.18.0 (`require()`) +> v14.13.1, v12.20.0 (`import`) + +## 📖 Rule Details + +This rule enforces that `node:` protocol is prepended to built-in Node modules when importing or exporting built-in Node modules. + +👍 Examples of **correct** code for this rule: + +```js +/*eslint n/prefer-node-protocol: error */ + +import fs from "node:fs" + +export { promises } from "node:fs" + +const fs = require("node:fs") +``` + +👎 Examples of **incorrect** code for this rule: + +```js +/*eslint n/prefer-node-protocol: error */ + +import fs from "fs" + +export { promises } from "fs" + +const fs = require("fs") +``` + +### Configured Node.js version range + +[Configured Node.js version range](../../../README.md#configured-nodejs-version-range) + +### Options + +```json +{ + "n/prefer-node-protocol": ["error", { + "version": ">=16.0.0", + }] +} +``` + +#### version + +As mentioned above, this rule reads the [`engines`] field of `package.json`. +But, you can overwrite the version by `version` option. + +The `version` option accepts [the valid version range of `node-semver`](https://github.com/npm/node-semver#range-grammar). + +## 🔎 Implementation + +- [Rule source](../../lib/rules/prefer-node-protocol.js) +- [Test source](../../tests/lib/rules/prefer-node-protocol.js) diff --git a/lib/index.js b/lib/index.js index 23cd1937..6497d227 100644 --- a/lib/index.js +++ b/lib/index.js @@ -39,6 +39,7 @@ const rules = { "prefer-global/text-encoder": require("./rules/prefer-global/text-encoder"), "prefer-global/url-search-params": require("./rules/prefer-global/url-search-params"), "prefer-global/url": require("./rules/prefer-global/url"), + "prefer-node-protocol": require("./rules/prefer-node-protocol"), "prefer-promises/dns": require("./rules/prefer-promises/dns"), "prefer-promises/fs": require("./rules/prefer-promises/fs"), "process-exit-as-throw": require("./rules/process-exit-as-throw"), diff --git a/lib/rules/prefer-node-protocol.js b/lib/rules/prefer-node-protocol.js new file mode 100644 index 00000000..43e27061 --- /dev/null +++ b/lib/rules/prefer-node-protocol.js @@ -0,0 +1,150 @@ +/** + * @author Yusuke Iinuma + * See LICENSE file in root directory for full license. + */ +"use strict" + +const isBuiltinModule = require("is-builtin-module") +const getConfiguredNodeVersion = require("../util/get-configured-node-version") +const getSemverRange = require("../util/get-semver-range") +const visitImport = require("../util/visit-import") +const visitRequire = require("../util/visit-require") +const mergeVisitorsInPlace = require("../util/merge-visitors-in-place") + +const messageId = "preferNodeProtocol" + +module.exports = { + meta: { + docs: { + description: + "enforce using the `node:` protocol when importing Node.js builtin modules.", + recommended: false, + url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/prefer-node-protocol.md", + }, + fixable: "code", + messages: { + [messageId]: "Prefer `node:{{moduleName}}` over `{{moduleName}}`.", + }, + schema: [ + { + type: "object", + properties: { + version: getConfiguredNodeVersion.schema, + }, + additionalProperties: false, + }, + ], + type: "suggestion", + }, + create(context) { + function isCallExpression(node, { name, argumentsLength }) { + if (node?.type !== "CallExpression") { + return false + } + + if (node.optional) { + return false + } + + if (node.arguments.length !== argumentsLength) { + return false + } + + if ( + node.callee.type !== "Identifier" || + node.callee.name !== name + ) { + return false + } + + return true + } + + function isStringLiteral(node) { + return node?.type === "Literal" && typeof node.type === "string" + } + + function isStaticRequire(node) { + return ( + isCallExpression(node, { + name: "require", + argumentsLength: 1, + }) && isStringLiteral(node.arguments[0]) + ) + } + + function isEnablingThisRule(context, moduleStyle) { + const version = getConfiguredNodeVersion(context) + + const supportedVersionForEsm = "^12.20.0 || >= 14.13.1" + // Only check Node.js version because this rule is meaningless if configured Node.js version doesn't match semver range. + if (!version.intersects(getSemverRange(supportedVersionForEsm))) { + return false + } + + const supportedVersionForCjs = "^14.18.0 || >= 16.0.0" + // Only check when using `require` + if ( + moduleStyle === "require" && + !version.intersects(getSemverRange(supportedVersionForCjs)) + ) { + return false + } + + return true + } + + const targets = [] + return [ + visitImport(context, { includeCore: true }, importTargets => { + targets.push(...importTargets) + }), + visitRequire(context, { includeCore: true }, requireTargets => { + targets.push( + ...requireTargets.filter(target => + isStaticRequire(target.node.parent) + ) + ) + }), + { + "Program:exit"() { + for (const { node, moduleStyle } of targets) { + if (!isEnablingThisRule(context, moduleStyle)) { + return + } + + if (node.type === "TemplateLiteral") { + continue + } + + const { value } = node + if ( + typeof value !== "string" || + value.startsWith("node:") || + !isBuiltinModule(value) || + !isBuiltinModule(`node:${value}`) + ) { + return + } + + context.report({ + node, + messageId, + fix(fixer) { + const firstCharacterIndex = node.range[0] + 1 + return fixer.replaceTextRange( + [firstCharacterIndex, firstCharacterIndex], + "node:" + ) + }, + }) + } + }, + }, + ].reduce( + (mergedVisitor, thisVisitor) => + mergeVisitorsInPlace(mergedVisitor, thisVisitor), + {} + ) + }, +} diff --git a/lib/util/strip-import-path-params.js b/lib/util/strip-import-path-params.js index 6b92cde0..c1afabdf 100644 --- a/lib/util/strip-import-path-params.js +++ b/lib/util/strip-import-path-params.js @@ -5,6 +5,6 @@ "use strict" module.exports = function stripImportPathParams(path) { - const i = path.indexOf("!") + const i = path.toString().indexOf("!") return i === -1 ? path : path.slice(0, i) } diff --git a/tests/lib/rules/prefer-node-protocol.js b/tests/lib/rules/prefer-node-protocol.js new file mode 100644 index 00000000..a9335d14 --- /dev/null +++ b/tests/lib/rules/prefer-node-protocol.js @@ -0,0 +1,221 @@ +/** + * @author Yusuke Iinuma + * See LICENSE file in root directory for full license. + */ +"use strict" + +const { RuleTester } = require("#eslint-rule-tester") +const rule = require("../../../lib/rules/prefer-node-protocol.js") + +new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + }, +}).run("prefer-node-protocol", rule, { + valid: [ + 'import nodePlugin from "eslint-plugin-n";', + 'import fs from "./fs";', + 'import fs from "unknown-builtin-module";', + 'import fs from "node:fs";', + ` + async function foo() { + const fs = await import(fs); + } + `, + ` + async function foo() { + const fs = await import(0); + } + `, + ` + async function foo() { + const fs = await import(\`fs\`); + } + `, + 'import "punycode/";', + // https://bun.sh/docs/runtime/bun-apis + 'import "bun";', + 'import "bun:jsc";', + 'import "bun:sqlite";', + 'export {promises} from "node:fs";', + + // `require` + 'const fs = require("node:fs");', + 'const fs = require("node:fs/promises");', + "const fs = require(fs);", + 'const fs = notRequire("fs");', + 'const fs = foo.require("fs");', + 'const fs = require.resolve("fs");', + "const fs = require(`fs`);", + 'const fs = require?.("fs");', + 'const fs = require("fs", extra);', + "const fs = require();", + 'const fs = require(...["fs"]);', + 'const fs = require("eslint-plugin-n");', + + // check disabling by supported Node.js versions + { + options: [{ version: "12.19.1" }], + code: 'import fs from "fs";', + }, + { + options: [{ version: "13.14.0" }], + code: 'import fs from "fs";', + }, + { + options: [{ version: "14.13.0" }], + code: 'import fs from "fs";', + }, + { + options: [{ version: "14.17.6" }], + code: 'const fs = require("fs");', + }, + { + options: [{ version: "15.14.0" }], + code: 'const fs = require("fs");', + }, + ], + invalid: [ + { + code: 'import fs from "fs";', + output: 'import fs from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'export {promises} from "fs";', + output: 'export {promises} from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: ` + async function foo() { + const fs = await import('fs'); + } + `, + output: ` + async function foo() { + const fs = await import('node:fs'); + } + `, + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'import fs from "fs/promises";', + output: 'import fs from "node:fs/promises";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'export {default} from "fs/promises";', + output: 'export {default} from "node:fs/promises";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: ` + async function foo() { + const fs = await import('fs/promises'); + } + `, + output: ` + async function foo() { + const fs = await import('node:fs/promises'); + } + `, + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'import {promises} from "fs";', + output: 'import {promises} from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'export {default as promises} from "fs";', + output: 'export {default as promises} from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: "import {promises} from 'fs';", + output: "import {promises} from 'node:fs';", + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: ` + async function foo() { + const fs = await import("fs/promises"); + } + `, + output: ` + async function foo() { + const fs = await import("node:fs/promises"); + } + `, + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: ` + async function foo() { + const fs = await import(/* escaped */"\\u{66}s/promises"); + } + `, + output: ` + async function foo() { + const fs = await import(/* escaped */"node:\\u{66}s/promises"); + } + `, + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'import "buffer";', + output: 'import "node:buffer";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'import "child_process";', + output: 'import "node:child_process";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: 'import "timers/promises";', + output: 'import "node:timers/promises";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + + // `require` + { + code: 'const {promises} = require("fs")', + output: 'const {promises} = require("node:fs")', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + code: "const fs = require('fs/promises')", + output: "const fs = require('node:fs/promises')", + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + + // check enabling by supported Node.js versions + { + options: [{ version: "12.20.0" }], + code: 'import fs from "fs";', + output: 'import fs from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + options: [{ version: "14.13.1" }], + code: 'import fs from "fs";', + output: 'import fs from "node:fs";', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + options: [{ version: "14.18.0" }], + code: 'const fs = require("fs");', + output: 'const fs = require("node:fs");', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + { + options: [{ version: "16.0.0" }], + code: 'const fs = require("fs");', + output: 'const fs = require("node:fs");', + errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."], + }, + ], +})