diff --git a/docs/rules/shebang.md b/docs/rules/shebang.md index 205e5d11..126daea5 100644 --- a/docs/rules/shebang.md +++ b/docs/rules/shebang.md @@ -61,7 +61,11 @@ console.log("hello"); ```json { - "n/shebang": ["error", {"convertPath": null}] + "n/shebang": ["error", { + "convertPath": null, + "ignoreUnpublished": false, + "additionalExecutables": [], + }] } ``` @@ -70,6 +74,14 @@ console.log("hello"); 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. +#### ignoreUnpublished + +Allow for files that are not published to npm to be ignored by this rule. + +#### additionalExecutables + +Mark files as executable that are not referenced by the package.json#bin property + ## 🔎 Implementation - [Rule source](../../lib/rules/shebang.js) diff --git a/eslint.config.js b/eslint.config.js index 3534ae88..6542c613 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,9 +13,6 @@ module.exports = [ { languageOptions: { globals: globals.mocha }, linterOptions: { reportUnusedDisableDirectives: true }, - settings: { - n: { allowModules: ["#eslint-rule-tester"] }, // the plugin does not support import-maps yet. - }, }, { ignores: [ @@ -23,7 +20,7 @@ module.exports = [ "coverage/", "docs/", "lib/converted-esm/", - "test/fixtures/", + "tests/fixtures/", ], }, js.configs.recommended, diff --git a/lib/rules/shebang.js b/lib/rules/shebang.js index 179f347c..c4f9d4ef 100644 --- a/lib/rules/shebang.js +++ b/lib/rules/shebang.js @@ -5,8 +5,11 @@ "use strict" const path = require("path") +const matcher = require("ignore") + const getConvertPath = require("../util/get-convert-path") const getPackageJson = require("../util/get-package-json") +const getNpmignore = require("../util/get-npmignore") const NODE_SHEBANG = "#!/usr/bin/env node\n" const SHEBANG_PATTERN = /^(#!.+?)?(\r)?\n/u @@ -66,6 +69,7 @@ function getShebangInfo(sourceCode) { } } +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { docs: { @@ -79,8 +83,12 @@ module.exports = { { type: "object", properties: { - // convertPath: getConvertPath.schema, + ignoreUnpublished: { type: "boolean" }, + additionalExecutables: { + type: "array", + items: { type: "string" }, + }, }, additionalProperties: false, }, @@ -95,30 +103,60 @@ module.exports = { }, create(context) { const sourceCode = context.sourceCode ?? context.getSourceCode() // TODO: just use context.sourceCode when dropping eslint < v9 - let filePath = context.filename ?? context.getFilename() + const filePath = context.filename ?? context.getFilename() if (filePath === "") { return {} } - filePath = path.resolve(filePath) const p = getPackageJson(filePath) if (!p) { return {} } - const basedir = path.dirname(p.filePath) - filePath = path.join( - basedir, - getConvertPath(context)( - path.relative(basedir, filePath).replace(/\\/gu, "/") - ) + const packageDirectory = path.dirname(p.filePath) + + const originalAbsolutePath = path.resolve(filePath) + const originalRelativePath = path + .relative(packageDirectory, originalAbsolutePath) + .replace(/\\/gu, "/") + + const convertedRelativePath = + getConvertPath(context)(originalRelativePath) + const convertedAbsolutePath = path.resolve( + packageDirectory, + convertedRelativePath ) - const needsShebang = isBinFile(filePath, p.bin, basedir) + const { additionalExecutables = [] } = context.options?.[0] ?? {} + + const executable = matcher() + executable.add(additionalExecutables) + const isExecutable = executable.test(convertedRelativePath) + + if ( + (additionalExecutables.length === 0 || + isExecutable.ignored === false) && + context.options?.[0]?.ignoreUnpublished === true + ) { + const npmignore = getNpmignore(convertedAbsolutePath) + + if (npmignore.match(convertedRelativePath)) { + return {} + } + } + + const needsShebang = + isExecutable.ignored === true || + isBinFile(convertedAbsolutePath, p.bin, packageDirectory) const info = getShebangInfo(sourceCode) return { - Program(node) { + Program() { + const loc = { + start: { line: 1, column: 0 }, + end: { line: 1, column: sourceCode.lines.at(0).length }, + } + if ( needsShebang ? NODE_SHEBANG_PATTERN.test(info.shebang) @@ -128,7 +166,7 @@ module.exports = { // Checks BOM and \r. if (needsShebang && info.bom) { context.report({ - node, + loc, messageId: "unexpectedBOM", fix(fixer) { return fixer.removeRange([-1, 0]) @@ -137,7 +175,7 @@ module.exports = { } if (needsShebang && info.cr) { context.report({ - node, + loc, messageId: "expectedLF", fix(fixer) { const index = sourceCode.text.indexOf("\r") @@ -148,7 +186,7 @@ module.exports = { } else if (needsShebang) { // Shebang is lacking. context.report({ - node, + loc, messageId: "expectedHashbangNode", fix(fixer) { return fixer.replaceTextRange( @@ -160,7 +198,7 @@ module.exports = { } else { // Shebang is extra. context.report({ - node, + loc, messageId: "expectedHashbang", fix(fixer) { return fixer.removeRange([0, info.length]) diff --git a/tests/fixtures/shebang/unpublished/package.json b/tests/fixtures/shebang/unpublished/package.json new file mode 100644 index 00000000..05fe8a95 --- /dev/null +++ b/tests/fixtures/shebang/unpublished/package.json @@ -0,0 +1,7 @@ +{ + "name": "test", + "version": "0.0.0", + "files": [ + "./published.js" + ] +} diff --git a/tests/lib/rules/shebang.js b/tests/lib/rules/shebang.js index 47d9460c..f0e0f9c1 100644 --- a/tests/lib/rules/shebang.js +++ b/tests/lib/rules/shebang.js @@ -17,63 +17,82 @@ function fixture(name) { return path.resolve(__dirname, "../../fixtures/shebang", name) } +/** @type {import('eslint').RuleTester} */ const ruleTester = new RuleTester() ruleTester.run("shebang", rule, { valid: [ { + name: "string-bin/bin/test.js", filename: fixture("string-bin/bin/test.js"), code: "#!/usr/bin/env node\nhello();", }, { + name: "string-bin/lib/test.js", filename: fixture("string-bin/lib/test.js"), code: "hello();", }, { + name: "object-bin/bin/a.js", filename: fixture("object-bin/bin/a.js"), code: "#!/usr/bin/env node\nhello();", }, { + name: "object-bin/bin/b.js", filename: fixture("object-bin/bin/b.js"), code: "#!/usr/bin/env node\nhello();", }, { + name: "object-bin/bin/c.js", filename: fixture("object-bin/bin/c.js"), code: "hello();", }, { + name: "no-bin-field/lib/test.js", filename: fixture("no-bin-field/lib/test.js"), code: "hello();", }, - "#!/usr/bin/env node\nhello();", - "hello();", + { + name: " with shebang", + code: "#!/usr/bin/env node\nhello();", + }, + { + name: " without shebang", + code: "hello();", + }, // convertPath { + name: "convertPath - string-bin/src/bin/test.js", filename: fixture("string-bin/src/bin/test.js"), code: "#!/usr/bin/env node\nhello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], }, { + name: "convertPath - string-bin/src/lib/test.js", filename: fixture("string-bin/src/lib/test.js"), code: "hello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], }, { + name: "convertPath - object-bin/src/bin/a.js", filename: fixture("object-bin/src/bin/a.js"), code: "#!/usr/bin/env node\nhello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], }, { + name: "convertPath - object-bin/src/bin/b.js", filename: fixture("object-bin/src/bin/b.js"), code: "#!/usr/bin/env node\nhello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], }, { + name: "convertPath - object-bin/src/bin/c.js", filename: fixture("object-bin/src/bin/c.js"), code: "hello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], }, { + name: "convertPath - no-bin-field/src/lib/test.js", filename: fixture("no-bin-field/src/lib/test.js"), code: "hello();", options: [{ convertPath: { "src/**": ["^src/(.+)$", "$1"] } }], @@ -81,88 +100,131 @@ ruleTester.run("shebang", rule, { // Should work fine if the filename is relative. { + name: "relative path - string-bin/bin/test.js", filename: "tests/fixtures/shebang/string-bin/bin/test.js", code: "#!/usr/bin/env node\nhello();", }, { + name: "relative path - string-bin/lib/test.js", filename: "tests/fixtures/shebang/string-bin/lib/test.js", code: "hello();", }, // BOM and \r\n { + name: "BOM without newline", filename: fixture("string-bin/lib/test.js"), code: "\uFEFFhello();", }, { + name: "BOM with newline", filename: fixture("string-bin/lib/test.js"), code: "\uFEFFhello();\n", }, { + name: "with windows newline", filename: fixture("string-bin/lib/test.js"), code: "hello();\r\n", }, { + name: "BOM with windows newline", filename: fixture("string-bin/lib/test.js"), code: "\uFEFFhello();\r\n", }, // blank lines on the top of files. { + name: "blank lines on the top of files.", filename: fixture("string-bin/lib/test.js"), code: "\n\n\nhello();", }, // https://github.com/mysticatea/eslint-plugin-node/issues/51 { + name: "Shebang with CLI flags", filename: fixture("string-bin/bin/test.js"), code: "#!/usr/bin/env node --harmony\nhello();", }, // use node resolution { + name: "use node resolution", filename: fixture("object-bin/bin/index.js"), code: "#!/usr/bin/env node\nhello();", }, + + // npm unpublished files are ignored + { + name: "published file cant have shebang", + filename: fixture("unpublished/published.js"), + code: "hello();", + options: [{ ignoreUnpublished: true }], + }, + { + name: "unpublished file can have shebang", + filename: fixture("unpublished/unpublished.js"), + code: "#!/usr/bin/env node\nhello();", + options: [{ ignoreUnpublished: true }], + }, + { + name: "unpublished file can have noshebang", + filename: fixture("unpublished/unpublished.js"), + code: "hello();", + options: [{ ignoreUnpublished: true }], + }, + + { + name: "file matching additionalExecutables", + filename: fixture("unpublished/something.test.js"), + code: "#!/usr/bin/env node\nhello();", + options: [{ additionalExecutables: ["*.test.js"] }], + }, ], invalid: [ { + name: "bin: string - match - no shebang", filename: fixture("string-bin/bin/test.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "bin: string - match - incorrect shebang", filename: fixture("string-bin/bin/test.js"), code: "#!/usr/bin/node\nhello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "bin: string - no match - with shebang", filename: fixture("string-bin/lib/test.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", errors: ["This file needs no shebang."], }, { + name: 'bin: {a: "./bin/a.js"} - match - no shebang', filename: fixture("object-bin/bin/a.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: 'bin: {b: "./bin/b.js"} - match - no shebang', filename: fixture("object-bin/bin/b.js"), code: "#!/usr/bin/node\nhello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: 'bin: {c: "./bin"} - no match - with shebang', filename: fixture("object-bin/bin/c.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", errors: ["This file needs no shebang."], }, { + name: "bin: undefined - no match - with shebang", filename: fixture("no-bin-field/lib/test.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", @@ -171,6 +233,7 @@ ruleTester.run("shebang", rule, { // convertPath { + name: "convertPath in options", filename: fixture("string-bin/src/bin/test.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", @@ -178,6 +241,7 @@ ruleTester.run("shebang", rule, { errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "convertPath in settings", filename: fixture("string-bin/src/bin/test.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", @@ -187,6 +251,7 @@ ruleTester.run("shebang", rule, { }, }, { + name: "converted path - string-bin/src/bin/test.js", filename: fixture("string-bin/src/bin/test.js"), code: "#!/usr/bin/node\nhello();", output: "#!/usr/bin/env node\nhello();", @@ -194,6 +259,7 @@ ruleTester.run("shebang", rule, { errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "converted path - string-bin/src/lib/test.js", filename: fixture("string-bin/src/lib/test.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", @@ -201,6 +267,7 @@ ruleTester.run("shebang", rule, { errors: ["This file needs no shebang."], }, { + name: "converted path - object-bin/src/bin/a.js", filename: fixture("object-bin/src/bin/a.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", @@ -208,6 +275,7 @@ ruleTester.run("shebang", rule, { errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "converted path - object-bin/src/bin/b.js", filename: fixture("object-bin/src/bin/b.js"), code: "#!/usr/bin/node\nhello();", output: "#!/usr/bin/env node\nhello();", @@ -215,6 +283,7 @@ ruleTester.run("shebang", rule, { errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "converted path - object-bin/src/bin/c.js", filename: fixture("object-bin/src/bin/c.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", @@ -222,6 +291,7 @@ ruleTester.run("shebang", rule, { errors: ["This file needs no shebang."], }, { + name: "converted path - no-bin-field/src/lib/test.js", filename: fixture("no-bin-field/src/lib/test.js"), code: "#!/usr/bin/env node\nhello();", output: "hello();", @@ -231,12 +301,14 @@ ruleTester.run("shebang", rule, { // Should work fine if the filename is relative. { + name: "relative path - string-bin/bin/test.js", filename: "tests/fixtures/shebang/string-bin/bin/test.js", code: "hello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, { + name: "relative path - string-bin/lib/test.js", filename: "tests/fixtures/shebang/string-bin/lib/test.js", code: "#!/usr/bin/env node\nhello();", output: "hello();", @@ -245,6 +317,7 @@ ruleTester.run("shebang", rule, { // header comments { + name: "header comments", filename: fixture("string-bin/bin/test.js"), code: "/* header */\nhello();", output: "#!/usr/bin/env node\n/* header */\nhello();", @@ -306,6 +379,7 @@ ruleTester.run("shebang", rule, { // https://github.com/mysticatea/eslint-plugin-node/issues/51 { + name: "Shebang with CLI flags", filename: fixture("string-bin/lib/test.js"), code: "#!/usr/bin/env node --harmony\nhello();", output: "hello();", @@ -314,10 +388,53 @@ ruleTester.run("shebang", rule, { // use node resolution { + name: "use node resolution", filename: fixture("object-bin/bin/index.js"), code: "hello();", output: "#!/usr/bin/env node\nhello();", errors: ['This file needs shebang "#!/usr/bin/env node".'], }, + + // npm unpublished files are ignored + { + name: "unpublished file should not have shebang", + filename: fixture("unpublished/unpublished.js"), + code: "#!/usr/bin/env node\nhello();", + output: "hello();", + errors: ["This file needs no shebang."], + }, + { + name: "published file should have shebang", + filename: fixture("unpublished/published.js"), + code: "#!/usr/bin/env node\nhello();", + output: "hello();", + errors: ["This file needs no shebang."], + }, + + { + name: "unpublished file shebang ignored", + filename: fixture("unpublished/published.js"), + code: "#!/usr/bin/env node\nhello();", + options: [{ ignoreUnpublished: true }], + output: "hello();", + errors: ["This file needs no shebang."], + }, + + { + name: "executable in additionalExecutables without shebang", + filename: fixture("unpublished/something.test.js"), + code: "hello();", + options: [{ additionalExecutables: ["*.test.js"] }], + output: "#!/usr/bin/env node\nhello();", + errors: ['This file needs shebang "#!/usr/bin/env node".'], + }, + { + name: "file not in additionalExecutables with shebang", + filename: fixture("unpublished/not-a-test.js"), + code: "#!/usr/bin/env node\nhello();", + options: [{ additionalExecutables: ["*.test.js"] }], + output: "hello();", + errors: ["This file needs no shebang."], + }, ], })