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..01171d8f --- /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 HASHBANG_ENV = "#!/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(HASHBANG_ENV)) +} + +/** @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..4afec6ac --- /dev/null +++ b/tests/lib/rules/no-top-level-await.js @@ -0,0 +1,234 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +"use strict" + +const { RuleTester } = require("#test-helpers") +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: { ecmaVersion: 2022, 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, + }, + ], + }, + // 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')", + }, + { + 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, + }, + ], + }, + // 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, + }, + ], + }, + ], +})