diff --git a/config/recommended.js b/config/recommended.js index 8e7ca9fd05..b63fda9d20 100644 --- a/config/recommended.js +++ b/config/recommended.js @@ -17,6 +17,7 @@ module.exports = { 'import/no-named-as-default': 'warn', 'import/no-named-as-default-member': 'warn', 'import/no-duplicates': 'warn', + 'import/prefer-node-builtins-imports': 'warn', }, // need all these for parsing dependencies (even if _your_ code doesn't need diff --git a/docs/rules/prefer-node-builtin-imports.md b/docs/rules/prefer-node-builtin-imports.md new file mode 100644 index 0000000000..91121109f9 --- /dev/null +++ b/docs/rules/prefer-node-builtin-imports.md @@ -0,0 +1,39 @@ +# import/prefer-node-builtins-imports + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Reports when there is no `node:` protocol for builtin modules. + +```ts +import path from "node:path"; +``` + +## Rule Details + +This rule enforces that builtins node imports are using `node:` protocol. It resolved the conflict of a module (npm-installed) in `node_modules` overriding the built-in module. Besides that, it is also clear that a built-in Node.js module is imported. + +## Examples + +❌ Invalid + +```ts +import fs from "fs"; +export { promises } from "fs"; +// require +const fs = require("fs/promises"); +``` + +✅ Valid + +```ts +import fs from "node:fs"; +export { promises } from "node:fs"; +// require +const fs = require("node:fs/promises"); +``` + +## When Not To Use It + +If you are using browser or Bun or Deno since this rule doesn't do anything with them. diff --git a/src/index.js b/src/index.js index feafba9003..ed661a6447 100644 --- a/src/index.js +++ b/src/index.js @@ -43,6 +43,7 @@ export const rules = { 'dynamic-import-chunkname': require('./rules/dynamic-import-chunkname'), 'no-import-module-exports': require('./rules/no-import-module-exports'), 'no-empty-named-blocks': require('./rules/no-empty-named-blocks'), + 'prefer-node-builtins-imports': require('./rules/prefer-node-builtin-imports'), // export 'exports-last': require('./rules/exports-last'), diff --git a/src/rules/prefer-node-builtin-imports.js b/src/rules/prefer-node-builtin-imports.js new file mode 100644 index 0000000000..ef780621da --- /dev/null +++ b/src/rules/prefer-node-builtin-imports.js @@ -0,0 +1,101 @@ +'use strict'; + +const { builtinModules } = require('module'); +const { default: docsUrl } = require('../docsUrl'); + +const MESSAGE_ID = 'prefer-node-builtin-imports'; +const messages = { + [MESSAGE_ID]: 'Prefer `node:{{moduleName}}` over `{{moduleName}}`.', +}; + +function replaceStringLiteral( + fixer, + node, + text, + relativeRangeStart, + relativeRangeEnd, +) { + const firstCharacterIndex = node.range[0] + 1; + const start = Number.isInteger(relativeRangeEnd) + ? relativeRangeStart + firstCharacterIndex + : firstCharacterIndex; + const end = Number.isInteger(relativeRangeEnd) + ? relativeRangeEnd + firstCharacterIndex + : node.range[1] - 1; + + return fixer.replaceTextRange([start, end], text); +} + +const isStringLiteral = (node) => node.type === 'Literal' && typeof node.value === 'string'; + +const isStaticRequireWith1Param = (node) => !node.optional + && node.callee.type === 'Identifier' + && node.callee.name === 'require' + && node.arguments[0] + // check for only 1 argument + && !node.arguments[1]; + +function checkAndReport(src, ctx) { + const { value } = src; + + if (!builtinModules.includes(value)) { return; } + + if (value.startsWith('node:')) { return; } + + ctx.report({ + node: src, + messageId: MESSAGE_ID, + data: { moduleName: value }, + /** @param {import('eslint').Rule.RuleFixer} fixer */ + fix(fixer) { + return replaceStringLiteral(fixer, src, 'node:', 0, 0); + }, + }); +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer using the `node:` protocol when importing Node.js builtin modules.', + recommended: true, + category: 'Best Practices', + url: docsUrl('prefer-node-builin-imports'), + }, + fixable: 'code', + schema: [], + messages, + }, + create(ctx) { + return { + CallExpression(node) { + if (!isStaticRequireWith1Param(node)) { + return; + } + + if (!isStringLiteral(node.arguments[0])) { + return; + } + + return checkAndReport(node.arguments[0], ctx); + }, + ExportNamedDeclaration(node) { + if (!isStringLiteral) { return; } + + return checkAndReport(node.source, ctx); + }, + ImportDeclaration(node) { + if (!isStringLiteral) { return; } + + return checkAndReport(node.source, ctx); + }, + ImportExpression(node) { + if (!isStringLiteral) { return; } + + return checkAndReport(node.source, ctx); + }, + }; + }, +}; diff --git a/tests/src/rules/prefer-node-builtin-imports.js b/tests/src/rules/prefer-node-builtin-imports.js new file mode 100644 index 0000000000..7980391582 --- /dev/null +++ b/tests/src/rules/prefer-node-builtin-imports.js @@ -0,0 +1,83 @@ +import { test } from '../utils'; + +import { RuleTester } from 'eslint'; + +const ruleTester = new RuleTester(); +const rule = require('rules/prefer-node-builtin-imports'); + +ruleTester.run('prefer-node-builtin-imports', rule, { + valid: [ + test({ code: 'import unicorn from "unicorn";' }), + test({ code: 'import fs from "./fs";' }), + test({ code: 'import fs from "unknown-builtin-module";' }), + test({ code: 'import fs from "node:fs";' }), + test({ + code: ` + async function foo() { + const fs = await import(fs); + }`, + }), + test({ + code: ` + async function foo() { + const fs = await import(0); + }`, + }), + test({ + code: ` + async function foo() { + const fs = await import(\`fs\`); + }`, + }), + test({ code: 'import "punycode/";' }), + test({ code: 'const fs = require("node:fs");' }), + test({ code: 'const fs = require("node:fs/promises");' }), + test({ code: 'const fs = require(fs);' }), + test({ code: 'const fs = notRequire("fs");' }), + test({ code: 'const fs = foo.require("fs");' }), + test({ code: 'const fs = require.resolve("fs");' }), + test({ code: 'const fs = require(`fs`);' }), + test({ code: 'const fs = require?.("fs");' }), + test({ code: 'const fs = require("fs", extra);' }), + test({ code: 'const fs = require();' }), + test({ code: 'const fs = require(...["fs"]);' }), + test({ code: 'const fs = require("unicorn");' }), + ], + invalid: [ + test({ code: 'import fs from "fs";' }), + test({ code: 'export {promises} from "fs";' }), + test({ + code: ` + async function foo() { + const fs = await import('fs'); + }`, + }), + test({ code: 'import fs from "fs/promises";' }), + test({ code: 'export {default} from "fs/promises";' }), + test({ + code: ` + async function foo() { + const fs = await import('fs/promises'); + }`, + }), + test({ code: 'import {promises} from "fs";' }), + test({ code: 'export {default as promises} from "fs";' }), + test({ + code: ` + async function foo() { + const fs = await import("fs/promises"); + }`, + }), + test({ + code: ` + async function foo() { + const fs = await import(/* escaped */"\\u{66}s/promises"); + `, + }), + test({ code: 'import "buffer";' }), + test({ code: 'import "child_process";' }), + test({ code: 'import "timers/promises";' }), + test({ code: 'const {promises} = require("fs")' }), + test({ code: 'const fs = require("fs/promises")' }), + ], +});