From 00ac7d158677cd8310e05ceb3f0a246cdabc8d80 Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Fri, 9 May 2025 16:56:41 +0900 Subject: [PATCH 1/4] feat: add `no-top-level-await` rule. --- README.md | 1 + docs/rules/no-top-level-await.md | 64 ++++++ lib/index.js | 1 + lib/rules/no-top-level-await.js | 132 +++++++++++ .../simple-bin/package.json | 5 + .../simple-files/package.json | 7 + .../simple-npmignore/.npmignore | 1 + .../simple-npmignore/package.json | 4 + tests/lib/rules/no-top-level-await.js | 209 ++++++++++++++++++ 9 files changed, 424 insertions(+) create mode 100644 docs/rules/no-top-level-await.md create mode 100644 lib/rules/no-top-level-await.js create mode 100644 tests/fixtures/no-top-level-await/simple-bin/package.json create mode 100644 tests/fixtures/no-top-level-await/simple-files/package.json create mode 100644 tests/fixtures/no-top-level-await/simple-npmignore/.npmignore create mode 100644 tests/fixtures/no-top-level-await/simple-npmignore/package.json create mode 100644 tests/lib/rules/no-top-level-await.js diff --git a/README.md b/README.md index d8c47d34..82a0504c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable | [no-restricted-import](docs/rules/no-restricted-import.md) | disallow specified modules when loaded by `import` declarations | | | | | [no-restricted-require](docs/rules/no-restricted-require.md) | disallow specified modules when loaded by `require` | | | | | [no-sync](docs/rules/no-sync.md) | disallow synchronous methods | | | | +| [no-top-level-await](docs/rules/no-top-level-await.md) | disallow top-level `await` in published modules | | | | | [no-unpublished-bin](docs/rules/no-unpublished-bin.md) | disallow `bin` files that npm ignores | 🟢 ✅ | | | | [no-unpublished-import](docs/rules/no-unpublished-import.md) | disallow `import` declarations which import private modules | 🟢 ✅ | | | | [no-unpublished-require](docs/rules/no-unpublished-require.md) | disallow `require()` expressions which import private modules | 🟢 ✅ | | | diff --git a/docs/rules/no-top-level-await.md b/docs/rules/no-top-level-await.md new file mode 100644 index 00000000..26c37d26 --- /dev/null +++ b/docs/rules/no-top-level-await.md @@ -0,0 +1,64 @@ +# Disallow top-level `await` in published modules (`n/no-top-level-await`) + + + +Node.js v20.19 introduced `require(esm)`, but ES modules with top-level `await` cannot be loaded with `require(esm)`. It is a good idea to disallow top-level `await` to ensure interoperability of modules published as Node.js libraries. + +## 📖 Rule Details + +If a source code file satisfies all of the following conditions, the file is \*published\*. + +- `"files"` field of `package.json` includes the file or `"files"` field of `package.json` does not exist. +- `.npmignore` does not include the file. + +Then this rule warns top-level `await` in \*published\* files. + +Examples of 👎 **incorrect** code for this rule: + +```js +/*eslint n/no-top-level-await: error*/ + +const foo = await import('foo'); +for await (const e of asyncIterate()) { + // ... +} +``` + +### Options + +```json +{ + "rules": { + "n/no-top-level-await": ["error", { + "ignoreBin": false, + "convertPath": null + }] + } +} +``` + +#### ignoreBin + +If `true`, this rule ignores top-level `await` in files that are specified in the `bin` field of `package.json` or in files that contain a `#!/usr/bin/env` in their source code. + +Examples of 👍 **correct** code for the `"ignoreBin": true` option: + +```js +#!/usr/bin/env node +/*eslint n/no-top-level-await: ["error", { "ignoreBin": true }]*/ + +const foo = await import('foo'); +for await (const e of asyncIterate()) { + // ... +} +``` + +#### convertPath + +This can be configured in the rule options or as a shared setting [`settings.convertPath`](../shared-settings.md#convertpath). +Please see the shared settings documentation for more information. + +## 🔎 Implementation + +- [Rule source](../../lib/rules/no-top-level-await.js) +- [Test source](../../tests/lib/rules/no-top-level-await.js) diff --git a/lib/index.js b/lib/index.js index de952189..5d40dd21 100644 --- a/lib/index.js +++ b/lib/index.js @@ -34,6 +34,7 @@ const base = { "no-restricted-import": require("./rules/no-restricted-import"), "no-restricted-require": require("./rules/no-restricted-require"), "no-sync": require("./rules/no-sync"), + "no-top-level-await": require("./rules/no-top-level-await"), "no-unpublished-bin": require("./rules/no-unpublished-bin"), "no-unpublished-import": require("./rules/no-unpublished-import"), "no-unpublished-require": require("./rules/no-unpublished-require"), diff --git a/lib/rules/no-top-level-await.js b/lib/rules/no-top-level-await.js new file mode 100644 index 00000000..d3bea27e --- /dev/null +++ b/lib/rules/no-top-level-await.js @@ -0,0 +1,132 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +"use strict" + +const path = require("path") +const { getSourceCode } = require("../util/eslint-compat") +const getConvertPath = require("../util/get-convert-path") +const { getPackageJson } = require("../util/get-package-json") +const { isBinFile } = require("../util/is-bin-file") +const getNpmignore = require("../util/get-npmignore") + +const ENV_HASHBANG = "#!/usr/bin/env" + +/** + * @typedef {[ + * { + * ignoreBin?:boolean + * convertPath?: import('../util/get-convert-path').ConvertPath; + * }? + * ]} RuleOptions + */ + +/** + * Checks whether the code has a hashbang comment or not. + * @param {import('./rule-module').PluginRuleContext<{RuleOptions: RuleOptions}>} context + */ +function hasHashbang(context) { + const sourceCode = getSourceCode(context) + return Boolean(sourceCode.text.startsWith(ENV_HASHBANG)) +} + +/** @param {import('./rule-module').PluginRuleContext<{RuleOptions: RuleOptions}>} context */ +function ignore(context) { + const options = context.options[0] || {} + const ignoreBin = options.ignoreBin ?? false + if (ignoreBin && hasHashbang(context)) { + // If the code has a hashbang comment, it is considered an executable file. + return true + } + + const filePath = context.filename ?? context.getFilename() + if (filePath === "") { + // The file path is "" (not specified), so it will be ignored. + return true + } + const originalAbsolutePath = path.resolve(filePath) + + // Find package.json + const packageJson = getPackageJson(originalAbsolutePath) + if (typeof packageJson?.filePath !== "string") { + // The file is not in a package, so it will be ignored. + return true + } + + // Convert by convertPath option + const packageDirectory = path.dirname(packageJson.filePath) + const convertedRelativePath = getConvertPath(context)( + path + .relative(packageDirectory, originalAbsolutePath) + .replace(/\\/gu, "/") + ) + const convertedAbsolutePath = path.resolve( + packageDirectory, + convertedRelativePath + ) + + if ( + ignoreBin && + isBinFile(convertedAbsolutePath, packageJson.bin, packageDirectory) + ) { + // The file is defined in the `bin` field of `package.json` + return true + } + + // Check ignored or not + const npmignore = getNpmignore(convertedAbsolutePath) + if (npmignore.match(convertedRelativePath)) { + // The file is unpublished file, so it will be ignored. + return true + } + + return false +} + +/** @type {import('./rule-module').RuleModule<{RuleOptions: RuleOptions}>} */ +module.exports = { + meta: { + docs: { + description: "disallow top-level `await` in published modules", + recommended: false, + url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/no-top-level-await.md", + }, + fixable: null, + messages: { + forbidden: "Top-level `await` is forbidden in published modules.", + }, + schema: [ + { + type: "object", + properties: { + ignoreBin: { type: "boolean" }, + convertPath: getConvertPath.schema, + }, + additionalProperties: false, + }, + ], + type: "problem", + }, + create(context) { + if (ignore(context)) { + return {} + } + let functionDepth = 0 + return { + ":function"() { + functionDepth++ + }, + ":function:exit"() { + functionDepth-- + }, + "AwaitExpression, ForOfStatement[await=true]"(node) { + if (functionDepth > 0) { + // not top-level + return + } + context.report({ node, messageId: "forbidden" }) + }, + } + }, +} diff --git a/tests/fixtures/no-top-level-await/simple-bin/package.json b/tests/fixtures/no-top-level-await/simple-bin/package.json new file mode 100644 index 00000000..af7c4ae6 --- /dev/null +++ b/tests/fixtures/no-top-level-await/simple-bin/package.json @@ -0,0 +1,5 @@ +{ + "name": "test", + "version": "1.0.0", + "bin": "a.js" +} diff --git a/tests/fixtures/no-top-level-await/simple-files/package.json b/tests/fixtures/no-top-level-await/simple-files/package.json new file mode 100644 index 00000000..168d6b56 --- /dev/null +++ b/tests/fixtures/no-top-level-await/simple-files/package.json @@ -0,0 +1,7 @@ +{ + "name": "test", + "version": "1.0.0", + "files": [ + "lib" + ] +} diff --git a/tests/fixtures/no-top-level-await/simple-npmignore/.npmignore b/tests/fixtures/no-top-level-await/simple-npmignore/.npmignore new file mode 100644 index 00000000..a57582cc --- /dev/null +++ b/tests/fixtures/no-top-level-await/simple-npmignore/.npmignore @@ -0,0 +1 @@ +/src diff --git a/tests/fixtures/no-top-level-await/simple-npmignore/package.json b/tests/fixtures/no-top-level-await/simple-npmignore/package.json new file mode 100644 index 00000000..f0c8e43c --- /dev/null +++ b/tests/fixtures/no-top-level-await/simple-npmignore/package.json @@ -0,0 +1,4 @@ +{ + "name": "test", + "version": "1.0.0" +} diff --git a/tests/lib/rules/no-top-level-await.js b/tests/lib/rules/no-top-level-await.js new file mode 100644 index 00000000..6c72a8f6 --- /dev/null +++ b/tests/lib/rules/no-top-level-await.js @@ -0,0 +1,209 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +"use strict" +const { RuleTester } = require("eslint") +const rule = require("../../../lib/rules/no-top-level-await.js") +const path = require("path") + +/** + * Makes a file path to a fixture. + * @param {string} name - A name. + * @returns {string} A file path to a fixture. + */ +function fixture(name) { + return path.resolve(__dirname, "../../fixtures/no-top-level-await", name) +} + +new RuleTester({ + languageOptions: { sourceType: "module" }, +}).run("no-top-level-await", rule, { + valid: [ + { + filename: fixture("simple-files/lib/a.js"), + code: "import * as foo from 'foo'", + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "for (const e of iterate()) { /* ... */ }", + }, + // Non Top-level `await` + { + filename: fixture("simple-bin/lib/a.js"), + code: "async function fn () { const foo = await import('foo') }", + }, + { + filename: fixture("simple-bin/lib/a.js"), + code: "async function fn () { for await (const e of asyncIterate()) { /* ... */ } }", + }, + { + filename: fixture("simple-bin/lib/a.js"), + code: "const fn = async () => await import('foo')", + }, + { + filename: fixture("simple-bin/lib/a.js"), + code: "const fn = async () => { for await (const e of asyncIterate()) { /* ... */ } }", + }, + // Ignore files + { + filename: fixture("simple-files/src/a.js"), + code: "const foo = await import('foo')", + }, + { + filename: fixture("simple-files/src/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + }, + { + filename: fixture("simple-npmignore/src/a.js"), + code: "const foo = await import('foo')", + }, + { + filename: fixture("simple-npmignore/src/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + }, + // ignoreBin + { + filename: fixture("simple-bin/a.js"), + code: "const foo = await import('foo')", + options: [ + { + ignoreBin: true, + }, + ], + }, + { + filename: fixture("simple-bin/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + options: [ + { + ignoreBin: true, + }, + ], + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "#!/usr/bin/env node\nconst foo = await import('foo')", + options: [ + { + ignoreBin: true, + }, + ], + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "#!/usr/bin/env node\nfor await (const e of asyncIterate()) { /* ... */ }", + options: [ + { + ignoreBin: true, + }, + ], + }, + // Unknown files + { + code: "const foo = await import('foo')", + }, + { + filename: "unknown.js", + code: "const foo = await import('foo')", + }, + ], + invalid: [ + { + filename: fixture("simple-files/lib/a.js"), + code: "const foo = await import('foo')", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 13, + }, + ], + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 1, + }, + ], + }, + { + filename: fixture("simple-npmignore/lib/a.js"), + code: "const foo = await import('foo')", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 13, + }, + ], + }, + { + filename: fixture("simple-npmignore/lib/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 1, + }, + ], + }, + { + filename: fixture("simple-bin/a.js"), + code: "const foo = await import('foo')", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 13, + }, + ], + }, + { + filename: fixture("simple-bin/a.js"), + code: "for await (const e of asyncIterate()) { /* ... */ }", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 1, + }, + ], + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "#!/usr/bin/env node\nconst foo = await import('foo')", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 2, + column: 13, + }, + ], + }, + { + filename: fixture("simple-files/lib/a.js"), + code: "#!/usr/bin/env node\nfor await (const e of asyncIterate()) { /* ... */ }", + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 2, + column: 1, + }, + ], + }, + ], +}) From a4cb0c1342130b40490f4c8ad4abe8bfcad0630f Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Fri, 9 May 2025 17:02:11 +0900 Subject: [PATCH 2/4] test: fix test for old node --- lib/rules/no-top-level-await.js | 4 ++-- tests/lib/rules/no-top-level-await.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rules/no-top-level-await.js b/lib/rules/no-top-level-await.js index d3bea27e..01171d8f 100644 --- a/lib/rules/no-top-level-await.js +++ b/lib/rules/no-top-level-await.js @@ -11,7 +11,7 @@ const { getPackageJson } = require("../util/get-package-json") const { isBinFile } = require("../util/is-bin-file") const getNpmignore = require("../util/get-npmignore") -const ENV_HASHBANG = "#!/usr/bin/env" +const HASHBANG_ENV = "#!/usr/bin/env" /** * @typedef {[ @@ -28,7 +28,7 @@ const ENV_HASHBANG = "#!/usr/bin/env" */ function hasHashbang(context) { const sourceCode = getSourceCode(context) - return Boolean(sourceCode.text.startsWith(ENV_HASHBANG)) + return Boolean(sourceCode.text.startsWith(HASHBANG_ENV)) } /** @param {import('./rule-module').PluginRuleContext<{RuleOptions: RuleOptions}>} context */ diff --git a/tests/lib/rules/no-top-level-await.js b/tests/lib/rules/no-top-level-await.js index 6c72a8f6..5506d208 100644 --- a/tests/lib/rules/no-top-level-await.js +++ b/tests/lib/rules/no-top-level-await.js @@ -3,7 +3,8 @@ * See LICENSE file in root directory for full license. */ "use strict" -const { RuleTester } = require("eslint") + +const { RuleTester } = require("#test-helpers") const rule = require("../../../lib/rules/no-top-level-await.js") const path = require("path") @@ -17,7 +18,7 @@ function fixture(name) { } new RuleTester({ - languageOptions: { sourceType: "module" }, + languageOptions: { ecmaVersion: 2020, sourceType: "module" }, }).run("no-top-level-await", rule, { valid: [ { From fa04fc2cfdc8b7f4ce4f3ee1b533005c3f8905de Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Fri, 9 May 2025 17:04:11 +0900 Subject: [PATCH 3/4] test: fix --- tests/lib/rules/no-top-level-await.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/rules/no-top-level-await.js b/tests/lib/rules/no-top-level-await.js index 5506d208..c9d20d8e 100644 --- a/tests/lib/rules/no-top-level-await.js +++ b/tests/lib/rules/no-top-level-await.js @@ -18,7 +18,7 @@ function fixture(name) { } new RuleTester({ - languageOptions: { ecmaVersion: 2020, sourceType: "module" }, + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, }).run("no-top-level-await", rule, { valid: [ { From 35494df93732a6329d70d83523066f3ddb8971ce Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Mon, 12 May 2025 15:44:24 +0900 Subject: [PATCH 4/4] test: add test for convertPath --- tests/lib/rules/no-top-level-await.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/lib/rules/no-top-level-await.js b/tests/lib/rules/no-top-level-await.js index c9d20d8e..4afec6ac 100644 --- a/tests/lib/rules/no-top-level-await.js +++ b/tests/lib/rules/no-top-level-await.js @@ -100,6 +100,14 @@ new RuleTester({ }, ], }, + // files field of `package.json` with convertPath + { + filename: fixture("simple-files/test/a.ts"), + code: "const foo = await import('foo')", + options: [ + { convertPath: { "src/**/*": ["src/(.+).ts", "lib/$1.js"] } }, + ], + }, // Unknown files { code: "const foo = await import('foo')", @@ -206,5 +214,21 @@ new RuleTester({ }, ], }, + // files field of `package.json` with convertPath + { + filename: fixture("simple-files/src/a.ts"), + code: "const foo = await import('foo')", + options: [ + { convertPath: { "src/**/*": ["src/(.+).ts", "lib/$1.js"] } }, + ], + errors: [ + { + message: + "Top-level `await` is forbidden in published modules.", + line: 1, + column: 13, + }, + ], + }, ], })