Skip to content

Commit 88d1c37

Browse files
authoredFeb 19, 2024··
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
1 parent 9930101 commit 88d1c37

File tree

6 files changed

+447
-1
lines changed

6 files changed

+447
-1
lines changed
 

‎README.md

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
143143
| [prefer-global/text-encoder](docs/rules/prefer-global/text-encoder.md) | enforce either `TextEncoder` or `require("util").TextEncoder` | | | |
144144
| [prefer-global/url](docs/rules/prefer-global/url.md) | enforce either `URL` or `require("url").URL` | | | |
145145
| [prefer-global/url-search-params](docs/rules/prefer-global/url-search-params.md) | enforce either `URLSearchParams` or `require("url").URLSearchParams` | | | |
146+
| [prefer-node-protocol](docs/rules/prefer-node-protocol.md) | enforce using the `node:` protocol when importing Node.js builtin modules. | | 🔧 | |
146147
| [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | |
147148
| [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | |
148149
| [process-exit-as-throw](docs/rules/process-exit-as-throw.md) | require that `process.exit()` expressions use the same code path as `throw` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |

‎docs/rules/prefer-node-protocol.md

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Enforce using the `node:` protocol when importing Node.js builtin modules (`n/prefer-node-protocol`)
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Older built-in Node modules such as fs now can be imported via either their name or `node:` + their name:
8+
9+
```js
10+
import fs from "fs"
11+
import fs from "node:fs"
12+
```
13+
14+
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.
15+
16+
Note that Node.js support for this feature began in:
17+
18+
> v16.0.0, v14.18.0 (`require()`)
19+
> v14.13.1, v12.20.0 (`import`)
20+
21+
## 📖 Rule Details
22+
23+
This rule enforces that `node:` protocol is prepended to built-in Node modules when importing or exporting built-in Node modules.
24+
25+
👍 Examples of **correct** code for this rule:
26+
27+
```js
28+
/*eslint n/prefer-node-protocol: error */
29+
30+
import fs from "node:fs"
31+
32+
export { promises } from "node:fs"
33+
34+
const fs = require("node:fs")
35+
```
36+
37+
👎 Examples of **incorrect** code for this rule:
38+
39+
```js
40+
/*eslint n/prefer-node-protocol: error */
41+
42+
import fs from "fs"
43+
44+
export { promises } from "fs"
45+
46+
const fs = require("fs")
47+
```
48+
49+
### Configured Node.js version range
50+
51+
[Configured Node.js version range](../../../README.md#configured-nodejs-version-range)
52+
53+
### Options
54+
55+
```json
56+
{
57+
"n/prefer-node-protocol": ["error", {
58+
"version": ">=16.0.0",
59+
}]
60+
}
61+
```
62+
63+
#### version
64+
65+
As mentioned above, this rule reads the [`engines`] field of `package.json`.
66+
But, you can overwrite the version by `version` option.
67+
68+
The `version` option accepts [the valid version range of `node-semver`](https://github.com/npm/node-semver#range-grammar).
69+
70+
## 🔎 Implementation
71+
72+
- [Rule source](../../lib/rules/prefer-node-protocol.js)
73+
- [Test source](../../tests/lib/rules/prefer-node-protocol.js)

‎lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const rules = {
3939
"prefer-global/text-encoder": require("./rules/prefer-global/text-encoder"),
4040
"prefer-global/url-search-params": require("./rules/prefer-global/url-search-params"),
4141
"prefer-global/url": require("./rules/prefer-global/url"),
42+
"prefer-node-protocol": require("./rules/prefer-node-protocol"),
4243
"prefer-promises/dns": require("./rules/prefer-promises/dns"),
4344
"prefer-promises/fs": require("./rules/prefer-promises/fs"),
4445
"process-exit-as-throw": require("./rules/process-exit-as-throw"),

‎lib/rules/prefer-node-protocol.js

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @author Yusuke Iinuma
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
"use strict"
6+
7+
const isBuiltinModule = require("is-builtin-module")
8+
const getConfiguredNodeVersion = require("../util/get-configured-node-version")
9+
const getSemverRange = require("../util/get-semver-range")
10+
const visitImport = require("../util/visit-import")
11+
const visitRequire = require("../util/visit-require")
12+
const mergeVisitorsInPlace = require("../util/merge-visitors-in-place")
13+
14+
const messageId = "preferNodeProtocol"
15+
16+
module.exports = {
17+
meta: {
18+
docs: {
19+
description:
20+
"enforce using the `node:` protocol when importing Node.js builtin modules.",
21+
recommended: false,
22+
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/prefer-node-protocol.md",
23+
},
24+
fixable: "code",
25+
messages: {
26+
[messageId]: "Prefer `node:{{moduleName}}` over `{{moduleName}}`.",
27+
},
28+
schema: [
29+
{
30+
type: "object",
31+
properties: {
32+
version: getConfiguredNodeVersion.schema,
33+
},
34+
additionalProperties: false,
35+
},
36+
],
37+
type: "suggestion",
38+
},
39+
create(context) {
40+
function isCallExpression(node, { name, argumentsLength }) {
41+
if (node?.type !== "CallExpression") {
42+
return false
43+
}
44+
45+
if (node.optional) {
46+
return false
47+
}
48+
49+
if (node.arguments.length !== argumentsLength) {
50+
return false
51+
}
52+
53+
if (
54+
node.callee.type !== "Identifier" ||
55+
node.callee.name !== name
56+
) {
57+
return false
58+
}
59+
60+
return true
61+
}
62+
63+
function isStringLiteral(node) {
64+
return node?.type === "Literal" && typeof node.type === "string"
65+
}
66+
67+
function isStaticRequire(node) {
68+
return (
69+
isCallExpression(node, {
70+
name: "require",
71+
argumentsLength: 1,
72+
}) && isStringLiteral(node.arguments[0])
73+
)
74+
}
75+
76+
function isEnablingThisRule(context, moduleStyle) {
77+
const version = getConfiguredNodeVersion(context)
78+
79+
const supportedVersionForEsm = "^12.20.0 || >= 14.13.1"
80+
// Only check Node.js version because this rule is meaningless if configured Node.js version doesn't match semver range.
81+
if (!version.intersects(getSemverRange(supportedVersionForEsm))) {
82+
return false
83+
}
84+
85+
const supportedVersionForCjs = "^14.18.0 || >= 16.0.0"
86+
// Only check when using `require`
87+
if (
88+
moduleStyle === "require" &&
89+
!version.intersects(getSemverRange(supportedVersionForCjs))
90+
) {
91+
return false
92+
}
93+
94+
return true
95+
}
96+
97+
const targets = []
98+
return [
99+
visitImport(context, { includeCore: true }, importTargets => {
100+
targets.push(...importTargets)
101+
}),
102+
visitRequire(context, { includeCore: true }, requireTargets => {
103+
targets.push(
104+
...requireTargets.filter(target =>
105+
isStaticRequire(target.node.parent)
106+
)
107+
)
108+
}),
109+
{
110+
"Program:exit"() {
111+
for (const { node, moduleStyle } of targets) {
112+
if (!isEnablingThisRule(context, moduleStyle)) {
113+
return
114+
}
115+
116+
if (node.type === "TemplateLiteral") {
117+
continue
118+
}
119+
120+
const { value } = node
121+
if (
122+
typeof value !== "string" ||
123+
value.startsWith("node:") ||
124+
!isBuiltinModule(value) ||
125+
!isBuiltinModule(`node:${value}`)
126+
) {
127+
return
128+
}
129+
130+
context.report({
131+
node,
132+
messageId,
133+
fix(fixer) {
134+
const firstCharacterIndex = node.range[0] + 1
135+
return fixer.replaceTextRange(
136+
[firstCharacterIndex, firstCharacterIndex],
137+
"node:"
138+
)
139+
},
140+
})
141+
}
142+
},
143+
},
144+
].reduce(
145+
(mergedVisitor, thisVisitor) =>
146+
mergeVisitorsInPlace(mergedVisitor, thisVisitor),
147+
{}
148+
)
149+
},
150+
}

‎lib/util/strip-import-path-params.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
"use strict"
66

77
module.exports = function stripImportPathParams(path) {
8-
const i = path.indexOf("!")
8+
const i = path.toString().indexOf("!")
99
return i === -1 ? path : path.slice(0, i)
1010
}
+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* @author Yusuke Iinuma
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
"use strict"
6+
7+
const { RuleTester } = require("#eslint-rule-tester")
8+
const rule = require("../../../lib/rules/prefer-node-protocol.js")
9+
10+
new RuleTester({
11+
languageOptions: {
12+
ecmaVersion: 2020,
13+
sourceType: "module",
14+
},
15+
}).run("prefer-node-protocol", rule, {
16+
valid: [
17+
'import nodePlugin from "eslint-plugin-n";',
18+
'import fs from "./fs";',
19+
'import fs from "unknown-builtin-module";',
20+
'import fs from "node:fs";',
21+
`
22+
async function foo() {
23+
const fs = await import(fs);
24+
}
25+
`,
26+
`
27+
async function foo() {
28+
const fs = await import(0);
29+
}
30+
`,
31+
`
32+
async function foo() {
33+
const fs = await import(\`fs\`);
34+
}
35+
`,
36+
'import "punycode/";',
37+
// https://bun.sh/docs/runtime/bun-apis
38+
'import "bun";',
39+
'import "bun:jsc";',
40+
'import "bun:sqlite";',
41+
'export {promises} from "node:fs";',
42+
43+
// `require`
44+
'const fs = require("node:fs");',
45+
'const fs = require("node:fs/promises");',
46+
"const fs = require(fs);",
47+
'const fs = notRequire("fs");',
48+
'const fs = foo.require("fs");',
49+
'const fs = require.resolve("fs");',
50+
"const fs = require(`fs`);",
51+
'const fs = require?.("fs");',
52+
'const fs = require("fs", extra);',
53+
"const fs = require();",
54+
'const fs = require(...["fs"]);',
55+
'const fs = require("eslint-plugin-n");',
56+
57+
// check disabling by supported Node.js versions
58+
{
59+
options: [{ version: "12.19.1" }],
60+
code: 'import fs from "fs";',
61+
},
62+
{
63+
options: [{ version: "13.14.0" }],
64+
code: 'import fs from "fs";',
65+
},
66+
{
67+
options: [{ version: "14.13.0" }],
68+
code: 'import fs from "fs";',
69+
},
70+
{
71+
options: [{ version: "14.17.6" }],
72+
code: 'const fs = require("fs");',
73+
},
74+
{
75+
options: [{ version: "15.14.0" }],
76+
code: 'const fs = require("fs");',
77+
},
78+
],
79+
invalid: [
80+
{
81+
code: 'import fs from "fs";',
82+
output: 'import fs from "node:fs";',
83+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
84+
},
85+
{
86+
code: 'export {promises} from "fs";',
87+
output: 'export {promises} from "node:fs";',
88+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
89+
},
90+
{
91+
code: `
92+
async function foo() {
93+
const fs = await import('fs');
94+
}
95+
`,
96+
output: `
97+
async function foo() {
98+
const fs = await import('node:fs');
99+
}
100+
`,
101+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
102+
},
103+
{
104+
code: 'import fs from "fs/promises";',
105+
output: 'import fs from "node:fs/promises";',
106+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
107+
},
108+
{
109+
code: 'export {default} from "fs/promises";',
110+
output: 'export {default} from "node:fs/promises";',
111+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
112+
},
113+
{
114+
code: `
115+
async function foo() {
116+
const fs = await import('fs/promises');
117+
}
118+
`,
119+
output: `
120+
async function foo() {
121+
const fs = await import('node:fs/promises');
122+
}
123+
`,
124+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
125+
},
126+
{
127+
code: 'import {promises} from "fs";',
128+
output: 'import {promises} from "node:fs";',
129+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
130+
},
131+
{
132+
code: 'export {default as promises} from "fs";',
133+
output: 'export {default as promises} from "node:fs";',
134+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
135+
},
136+
{
137+
code: "import {promises} from 'fs';",
138+
output: "import {promises} from 'node:fs';",
139+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
140+
},
141+
{
142+
code: `
143+
async function foo() {
144+
const fs = await import("fs/promises");
145+
}
146+
`,
147+
output: `
148+
async function foo() {
149+
const fs = await import("node:fs/promises");
150+
}
151+
`,
152+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
153+
},
154+
{
155+
code: `
156+
async function foo() {
157+
const fs = await import(/* escaped */"\\u{66}s/promises");
158+
}
159+
`,
160+
output: `
161+
async function foo() {
162+
const fs = await import(/* escaped */"node:\\u{66}s/promises");
163+
}
164+
`,
165+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
166+
},
167+
{
168+
code: 'import "buffer";',
169+
output: 'import "node:buffer";',
170+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
171+
},
172+
{
173+
code: 'import "child_process";',
174+
output: 'import "node:child_process";',
175+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
176+
},
177+
{
178+
code: 'import "timers/promises";',
179+
output: 'import "node:timers/promises";',
180+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
181+
},
182+
183+
// `require`
184+
{
185+
code: 'const {promises} = require("fs")',
186+
output: 'const {promises} = require("node:fs")',
187+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
188+
},
189+
{
190+
code: "const fs = require('fs/promises')",
191+
output: "const fs = require('node:fs/promises')",
192+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
193+
},
194+
195+
// check enabling by supported Node.js versions
196+
{
197+
options: [{ version: "12.20.0" }],
198+
code: 'import fs from "fs";',
199+
output: 'import fs from "node:fs";',
200+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
201+
},
202+
{
203+
options: [{ version: "14.13.1" }],
204+
code: 'import fs from "fs";',
205+
output: 'import fs from "node:fs";',
206+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
207+
},
208+
{
209+
options: [{ version: "14.18.0" }],
210+
code: 'const fs = require("fs");',
211+
output: 'const fs = require("node:fs");',
212+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
213+
},
214+
{
215+
options: [{ version: "16.0.0" }],
216+
code: 'const fs = require("fs");',
217+
output: 'const fs = require("node:fs");',
218+
errors: ["Prefer `node:{{moduleName}}` over `{{moduleName}}`."],
219+
},
220+
],
221+
})

0 commit comments

Comments
 (0)
Please sign in to comment.