From 12b06e655299879716613c09aaa1083526d155e9 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 15:00:02 -0400 Subject: [PATCH 1/6] feat: add require-read-r rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/require-read-r.md | 51 +++++++++++++++++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 ++ src/rules/require-read-r.spec.ts | 52 +++++++++++++++++++++++++++++ src/rules/require-read-r.ts | 56 ++++++++++++++++++++++++++++++++ tests/autofix.test.ts | 8 +++++ 7 files changed, 172 insertions(+) create mode 100644 docs/rules/require-read-r.md create mode 100644 src/rules/require-read-r.spec.ts create mode 100644 src/rules/require-read-r.ts diff --git a/README.md b/README.md index e21aaa7..834b11e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Each rule mirrors a well-known ShellCheck check. | [`no-unquoted-expansions`](./docs/rules/no-unquoted-expansions.md) | SC2086/46 | Quote expansions subject to word splitting and globbing | ✅ | error | | [`no-useless-cat`](./docs/rules/no-useless-cat.md) | SC2002 | Don't pipe from a single-file `cat` | | error | | [`no-useless-echo`](./docs/rules/no-useless-echo.md) | SC2116 | Disallow `$(echo ...)` | | error | +| [`require-read-r`](./docs/rules/require-read-r.md) | SC2162 | Use `read -r` so backslashes aren't mangled | ✅ | error | ## Configuration comments diff --git a/docs/rules/require-read-r.md b/docs/rules/require-read-r.md new file mode 100644 index 0000000..eb7d86b --- /dev/null +++ b/docs/rules/require-read-r.md @@ -0,0 +1,51 @@ +# require-read-r + +Require `read -r` so backslashes are not mangled. + +## Background + +By default, `read` treats backslashes in its input as escape characters: it removes them and joins lines that end in a backslash. Input such as `C:\temp` or a line ending in `\` is silently changed. The `-r` option makes `read` keep backslashes as they are, which is almost always what you want. + +## Rule Details + +This rule warns about `read` commands that don't pass the `-r` option. The option can appear alone (`-r`) or combined with other short options (`-rs`), anywhere before `--`. + +This rule is autofixable: it inserts `-r` immediately after `read`. + +Examples of **incorrect** code for this rule: + +```bash +# eslint bash/require-read-r: "error" + +read line + +read -p "Name: " name + +while read line; do echo "$line"; done < input.txt +``` + +Examples of **correct** code for this rule: + +```bash +# eslint bash/require-read-r: "error" + +read -r line + +read -r -p "Name: " name + +read -rs password + +while IFS= read -r line; do echo "$line"; done < input.txt +``` + +## Options + +This rule has no options. + +## When Not to Use It + +If you intentionally want `read` to interpret backslash escapes, for example to allow line continuations in input, you can disable this rule for those lines. + +## Prior Art + +- [SC2162](https://www.shellcheck.net/wiki/SC2162) diff --git a/src/index.spec.ts b/src/index.spec.ts index a6f3b24..9a079df 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -50,6 +50,7 @@ describe("plugin", () => { "no-unquoted-expansions", "no-useless-cat", "no-useless-echo", + "require-read-r", ]); }); diff --git a/src/index.ts b/src/index.ts index b438e29..c91ba5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import noLsIteration from "./rules/no-ls-iteration.js"; import noUnquotedExpansions from "./rules/no-unquoted-expansions.js"; import noUselessCat from "./rules/no-useless-cat.js"; import noUselessEcho from "./rules/no-useless-echo.js"; +import requireReadR from "./rules/require-read-r.js"; //------------------------------------------------------------------------------ // Plugin @@ -27,6 +28,7 @@ const rules = { "no-unquoted-expansions": noUnquotedExpansions, "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, + "require-read-r": requireReadR, }; const plugin = { @@ -54,6 +56,7 @@ const plugin = { "shell/no-unquoted-expansions": "error", "shell/no-useless-cat": "error", "shell/no-useless-echo": "error", + "shell/require-read-r": "error", }, }, }, diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts new file mode 100644 index 0000000..b061428 --- /dev/null +++ b/src/rules/require-read-r.spec.ts @@ -0,0 +1,52 @@ +/** + * @fileoverview Tests for the require-read-r rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./require-read-r.js"; + +const ruleTester = new RuleTester({ + plugins: { + bash: { + languages: { bash: new BashLanguage() }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. + } as any, + language: "bash/bash", +}); + +ruleTester.run("require-read-r", rule as never, { + valid: [ + "read -r line", + "read -rs password", + "read -r -a parts", + 'while read -r line; do echo "$line"; done < file', + // Not the read builtin + "gread line", + ], + invalid: [ + { + code: "read line", + output: "read -r line", + errors: [ + { + messageId: "missingR", + line: 1, + column: 1, + endColumn: 5, + }, + ], + }, + { + code: "read -s password", + output: "read -r -s password", + errors: [{ messageId: "missingR" }], + }, + { + code: 'while read line; do echo "$line"; done < file', + output: 'while read -r line; do echo "$line"; done < file', + errors: [{ messageId: "missingR" }], + }, + ], +}); diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts new file mode 100644 index 0000000..c30bf73 --- /dev/null +++ b/src/rules/require-read-r.ts @@ -0,0 +1,56 @@ +/** + * @fileoverview Rule to require the `-r` flag when using `read`. + * Mirrors ShellCheck SC2162. + */ + +import { getCommandName, getStaticText } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { + meta: { + type: "problem", + docs: { + description: "Require `read -r` so backslashes are not mangled", + recommended: true, + url: "https://github.com/eslint/bash/blob/main/docs/rules/require-read-r.md", + }, + fixable: "code", + schema: [], + messages: { + missingR: + "read without -r will mangle backslashes. (ShellCheck SC2162)", + }, + }, + + create(context) { + return { + Command(node) { + if (getCommandName(node) !== "read" || !node.name) { + return; + } + + for (const argument of node.arguments) { + const text = getStaticText(argument); + + if (text === "--") { + break; + } + + if (text !== null && /^-[a-zA-Z]*r/u.test(text)) { + return; + } + } + + const name = node.name; + + context.report({ + node: name, + messageId: "missingR", + fix: fixer => fixer.insertTextAfter(name, " -r"), + }); + }, + }; + }, +}; + +export default rule; diff --git a/tests/autofix.test.ts b/tests/autofix.test.ts index bb3b3bb..45c4090 100644 --- a/tests/autofix.test.ts +++ b/tests/autofix.test.ts @@ -54,4 +54,12 @@ describe("autofix", () => { expect(result.output).toBe('cp "$src" "$dest"\n'); }); + + it("should add -r to read", () => { + const result = fix('read line\necho "$line"\n', { + "bash/require-read-r": "error", + }); + + expect(result.output).toBe('read -r line\necho "$line"\n'); + }); }); From a5d7b101512d2ddfdfdcea746e7bbccbc0e699eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:19 +0000 Subject: [PATCH 2/6] fix: parse read option operands Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/require-read-r.spec.ts | 10 ++++++ src/rules/require-read-r.ts | 52 +++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index b061428..7eebfef 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -43,6 +43,16 @@ ruleTester.run("require-read-r", rule as never, { output: "read -r -s password", errors: [{ messageId: "missingR" }], }, + { + code: "read -d -r value", + output: "read -r -d -r value", + errors: [{ messageId: "missingR" }], + }, + { + code: "read -dr value", + output: "read -r -dr value", + errors: [{ messageId: "missingR" }], + }, { code: 'while read line; do echo "$line"; done < file', output: 'while read -r line; do echo "$line"; done < file', diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index c30bf73..231b124 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -6,6 +6,8 @@ import { getCommandName, getStaticText } from "./utils.js"; import type { BashRuleDefinition } from "../types.js"; +const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); + const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", @@ -29,18 +31,60 @@ const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { return; } - for (const argument of node.arguments) { - const text = getStaticText(argument); + let hasRawOption = false; + + for (let index = 0; index < node.arguments.length; index++) { + const argument = node.arguments[index]; + if (!argument) { + continue; + } + const text = getStaticText(argument); if (text === "--") { break; } - if (text !== null && /^-[a-zA-Z]*r/u.test(text)) { - return; + if ( + text === null || + !text.startsWith("-") || + text === "-" + ) { + continue; + } + + for ( + let optionIndex = 1; + optionIndex < text.length; + optionIndex++ + ) { + const option = text[optionIndex]; + if (!option) { + break; + } + + if (option === "r") { + hasRawOption = true; + break; + } + + if (optionsWithArguments.has(option)) { + if (optionIndex === text.length - 1) { + index++; + } + + break; + } + } + + if (hasRawOption) { + break; } } + if (hasRawOption) { + return; + } + const name = node.name; context.report({ From 0a5dd805c04fafb591b81e1066b0b1b2d5913cbd Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:50 -0400 Subject: [PATCH 3/6] fix: add languages and dialects to require-read-r meta Co-Authored-By: Claude Opus 5.5 --- src/rules/require-read-r.spec.ts | 1 + src/rules/require-read-r.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index 7eebfef..f64dd54 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -9,6 +9,7 @@ import rule from "./require-read-r.js"; const ruleTester = new RuleTester({ plugins: { bash: { + meta: { namespace: "shell" }, languages: { bash: new BashLanguage() }, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index 231b124..34d2cd6 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -11,9 +11,11 @@ const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", + languages: ["shell/bash"], docs: { description: "Require `read -r` so backslashes are not mangled", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/require-read-r.md", }, fixable: "code", From 232b5481770cecbbe14ab94f4a5b8b4b5c92aa90 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:41:16 -0400 Subject: [PATCH 4/6] fix: use the shell plugin namespace and type names in require-read-r Co-Authored-By: Claude Opus 5.5 --- docs/rules/require-read-r.md | 4 ++-- src/rules/require-read-r.spec.ts | 8 ++++---- src/rules/require-read-r.ts | 6 +++--- tests/autofix.test.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/rules/require-read-r.md b/docs/rules/require-read-r.md index eb7d86b..c24e313 100644 --- a/docs/rules/require-read-r.md +++ b/docs/rules/require-read-r.md @@ -15,7 +15,7 @@ This rule is autofixable: it inserts `-r` immediately after `read`. Examples of **incorrect** code for this rule: ```bash -# eslint bash/require-read-r: "error" +# eslint shell/require-read-r: "error" read line @@ -27,7 +27,7 @@ while read line; do echo "$line"; done < input.txt Examples of **correct** code for this rule: ```bash -# eslint bash/require-read-r: "error" +# eslint shell/require-read-r: "error" read -r line diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index f64dd54..ae9175a 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -3,18 +3,18 @@ */ import { RuleTester } from "eslint"; -import { BashLanguage } from "../languages/bash-language.js"; +import { ShellLanguage } from "../languages/shell-language.js"; import rule from "./require-read-r.js"; const ruleTester = new RuleTester({ plugins: { - bash: { + shell: { meta: { namespace: "shell" }, - languages: { bash: new BashLanguage() }, + languages: { bash: new ShellLanguage() }, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. } as any, - language: "bash/bash", + language: "shell/bash", }); ruleTester.run("require-read-r", rule as never, { diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index 34d2cd6..01c76f7 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -4,11 +4,11 @@ */ import { getCommandName, getStaticText } from "./utils.js"; -import type { BashRuleDefinition } from "../types.js"; +import type { ShellRuleDefinition } from "../types.js"; const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); -const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { +const rule: ShellRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", languages: ["shell/bash"], @@ -16,7 +16,7 @@ const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { description: "Require `read -r` so backslashes are not mangled", recommended: true, dialects: ["Bash", "POSIX sh", "mksh"], - url: "https://github.com/eslint/bash/blob/main/docs/rules/require-read-r.md", + url: "https://github.com/eslint/shell/blob/main/docs/rules/require-read-r.md", }, fixable: "code", schema: [], diff --git a/tests/autofix.test.ts b/tests/autofix.test.ts index 45c4090..dca33ff 100644 --- a/tests/autofix.test.ts +++ b/tests/autofix.test.ts @@ -57,7 +57,7 @@ describe("autofix", () => { it("should add -r to read", () => { const result = fix('read line\necho "$line"\n', { - "bash/require-read-r": "error", + "shell/require-read-r": "error", }); expect(result.output).toBe('read -r line\necho "$line"\n'); From 8b11d7813086fd1331400dbe65ee9df1dcdaa4ea Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:42:53 -0400 Subject: [PATCH 5/6] fix: allow require-read-r with every shell language The rule only listed shell/bash in meta.languages, so ESLint rejected it when used with the shell/posix or shell/mksh language. Co-Authored-By: Claude Opus 5.5 --- src/rules/require-read-r.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index 01c76f7..161b898 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -11,7 +11,7 @@ const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); const rule: ShellRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", - languages: ["shell/bash"], + languages: ["shell/bash", "shell/posix", "shell/mksh"], docs: { description: "Require `read -r` so backslashes are not mangled", recommended: true, From 0606f668d563bc8c92b94e4e8e270564519ee616 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 22 Oct 2026 15:43:53 -0400 Subject: [PATCH 6/6] chore: apply source file conventions Add section headers and an @author tag to the fileoverview comment of each source file to match the conventions used in other ESLint language plugins. Co-Authored-By: Claude Opus 5.5 --- src/rules/require-read-r.spec.ts | 9 +++++++++ src/rules/require-read-r.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index ae9175a..f03d72a 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -1,11 +1,20 @@ /** * @fileoverview Tests for the require-read-r rule. + * @author Claude Opus 5.5 */ +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + import { RuleTester } from "eslint"; import { ShellLanguage } from "../languages/shell-language.js"; import rule from "./require-read-r.js"; +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + const ruleTester = new RuleTester({ plugins: { shell: { diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index 161b898..a6ab00c 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -1,13 +1,26 @@ /** * @fileoverview Rule to require the `-r` flag when using `read`. * Mirrors ShellCheck SC2162. + * @author Claude Opus 5.5 */ +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + import { getCommandName, getStaticText } from "./utils.js"; import type { ShellRuleDefinition } from "../types.js"; +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + const rule: ShellRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem",