From 2b9d1a123799a403cdabaf531dde794bcbc911db Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 14:59:16 -0400 Subject: [PATCH 1/5] feat: add no-useless-echo rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/no-useless-echo.md | 53 +++++++++++++++++++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 ++ src/rules/no-useless-echo.spec.ts | 48 ++++++++++++++++++++++++++++ src/rules/no-useless-echo.ts | 46 +++++++++++++++++++++++++++ 6 files changed, 152 insertions(+) create mode 100644 docs/rules/no-useless-echo.md create mode 100644 src/rules/no-useless-echo.spec.ts create mode 100644 src/rules/no-useless-echo.ts diff --git a/README.md b/README.md index 7fd586e..7a051a3 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Each rule mirrors a well-known ShellCheck check. | [`no-backticks`](./docs/rules/no-backticks.md) | SC2006 | Use `$(...)` instead of legacy backticks | ✅ | error | | [`no-expansions-in-single-quotes`](./docs/rules/no-expansions-in-single-quotes.md) | SC2016 | Expressions don't expand in single quotes | | warn | | [`no-unquoted-expansions`](./docs/rules/no-unquoted-expansions.md) | SC2086/46 | Quote expansions subject to word splitting and globbing | ✅ | error | +| [`no-useless-echo`](./docs/rules/no-useless-echo.md) | SC2116 | Disallow `$(echo ...)` | | error | ## Configuration comments diff --git a/docs/rules/no-useless-echo.md b/docs/rules/no-useless-echo.md new file mode 100644 index 0000000..5010372 --- /dev/null +++ b/docs/rules/no-useless-echo.md @@ -0,0 +1,53 @@ +# no-useless-echo + +Disallow useless `echo` inside command substitutions. + +## Background + +A command substitution that only runs `echo`, such as `$(echo "$name")`, produces the same text that was passed to `echo`. It starts an extra process (or at least an extra step) and makes the code harder to read. Using the value directly is simpler. + +## Rule Details + +This rule warns about a command substitution, written as `$(...)` or with backticks, whose entire body is a single `echo` command without redirects. + +The rule doesn't inspect `echo`'s options, so `$(echo -n "$value")` is also reported. + +Substitutions that do more than echo, such as pipelines (`$(echo "$name" | tr a-z A-Z)`) or multiple commands, are not reported. + +Examples of **incorrect** code for this rule: + +```bash +# eslint bash/no-useless-echo: "error" + +name=$(echo "$first") + +ls $(echo /tmp) + +files=`echo "$dir"` +``` + +Examples of **correct** code for this rule: + +```bash +# eslint bash/no-useless-echo: "error" + +name="$first" + +upper=$(echo "$name" | tr a-z A-Z) + +both=$(echo hello; echo world) + +echo "hello" +``` + +## Options + +This rule has no options. + +## When Not to Use It + +`$(echo ...)` is occasionally used on purpose for side effects of `echo`, such as collapsing whitespace in an unquoted value or interpreting escape sequences with `echo -e`. If you rely on those behaviors, use a disable comment for those lines or disable this rule. + +## Prior Art + +- [SC2116](https://www.shellcheck.net/wiki/SC2116) diff --git a/src/index.spec.ts b/src/index.spec.ts index 17ec809..32f7e1e 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -38,6 +38,7 @@ describe("plugin", () => { "no-backticks", "no-expansions-in-single-quotes", "no-unquoted-expansions", + "no-useless-echo", ]); }); diff --git a/src/index.ts b/src/index.ts index 9922626..7bbaa47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,13 @@ import { ShellLanguage } from "./languages/shell-language.js"; import noBackticks from "./rules/no-backticks.js"; import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js"; import noUnquotedExpansions from "./rules/no-unquoted-expansions.js"; +import noUselessEcho from "./rules/no-useless-echo.js"; const rules = { "no-backticks": noBackticks, "no-expansions-in-single-quotes": noExpansionsInSingleQuotes, "no-unquoted-expansions": noUnquotedExpansions, + "no-useless-echo": noUselessEcho, }; const plugin = { @@ -36,6 +38,7 @@ const plugin = { "shell/no-backticks": "error", "shell/no-expansions-in-single-quotes": "warn", "shell/no-unquoted-expansions": "error", + "shell/no-useless-echo": "error", }, }, }, diff --git a/src/rules/no-useless-echo.spec.ts b/src/rules/no-useless-echo.spec.ts new file mode 100644 index 0000000..a99ecb3 --- /dev/null +++ b/src/rules/no-useless-echo.spec.ts @@ -0,0 +1,48 @@ +/** + * @fileoverview Tests for the no-useless-echo rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./no-useless-echo.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("no-useless-echo", rule as never, { + valid: [ + "echo hello", + "x=$(pwd)", + "x=$(echo foo | tr a-z A-Z)", + "x=$(echo foo; echo bar)", + "x=$(echo foo > file)", + ], + invalid: [ + { + code: "x=$(echo foo)", + errors: [ + { + messageId: "uselessEcho", + line: 1, + column: 3, + endColumn: 14, + }, + ], + }, + { + code: 'cmd "$(echo "$var")"', + errors: [{ messageId: "uselessEcho" }], + }, + { + code: "x=`echo foo`", + errors: [{ messageId: "uselessEcho" }], + }, + ], +}); diff --git a/src/rules/no-useless-echo.ts b/src/rules/no-useless-echo.ts new file mode 100644 index 0000000..54a73b6 --- /dev/null +++ b/src/rules/no-useless-echo.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview Rule to disallow `$(echo ...)` command substitutions. + * Mirrors ShellCheck SC2116. + */ + +import { isCommandNamed } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ MessageIds: "uselessEcho" }> = { + meta: { + type: "suggestion", + docs: { + description: "Disallow useless `echo` inside command substitutions", + recommended: true, + url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-echo.md", + }, + schema: [], + messages: { + uselessEcho: + "Useless echo? Instead of 'cmd $(echo foo)', just use 'cmd foo'. (ShellCheck SC2116)", + }, + }, + + create(context) { + return { + CommandSubstitution(node) { + const first = node.body[0]; + + if ( + node.body.length === 1 && + first !== undefined && + isCommandNamed(first, "echo") && + !first.negated && + first.redirects.length === 0 + ) { + context.report({ + node, + messageId: "uselessEcho", + }); + } + }, + }; + }, +}; + +export default rule; From 5ea878aaad4a0c0a37ae5e0a8851222164e8af7f 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:11 +0000 Subject: [PATCH 2/5] test: cover negated echo substitution Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/no-useless-echo.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rules/no-useless-echo.spec.ts b/src/rules/no-useless-echo.spec.ts index a99ecb3..5d28f1a 100644 --- a/src/rules/no-useless-echo.spec.ts +++ b/src/rules/no-useless-echo.spec.ts @@ -23,6 +23,7 @@ ruleTester.run("no-useless-echo", rule as never, { "x=$(echo foo | tr a-z A-Z)", "x=$(echo foo; echo bar)", "x=$(echo foo > file)", + "x=$(! echo foo)", ], invalid: [ { From 909e6d931f43981b3a7a85f91776f0a56b0eb93f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:43 -0400 Subject: [PATCH 3/5] fix: add languages and dialects to no-useless-echo meta Co-Authored-By: Claude Opus 5.5 --- src/rules/no-useless-echo.spec.ts | 1 + src/rules/no-useless-echo.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/no-useless-echo.spec.ts b/src/rules/no-useless-echo.spec.ts index 5d28f1a..2f449f3 100644 --- a/src/rules/no-useless-echo.spec.ts +++ b/src/rules/no-useless-echo.spec.ts @@ -9,6 +9,7 @@ import rule from "./no-useless-echo.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/no-useless-echo.ts b/src/rules/no-useless-echo.ts index 54a73b6..7a49a68 100644 --- a/src/rules/no-useless-echo.ts +++ b/src/rules/no-useless-echo.ts @@ -9,9 +9,11 @@ import type { BashRuleDefinition } from "../types.js"; const rule: BashRuleDefinition<{ MessageIds: "uselessEcho" }> = { meta: { type: "suggestion", + languages: ["shell/bash"], docs: { description: "Disallow useless `echo` inside command substitutions", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-echo.md", }, schema: [], From d2bf884657b9ae205fe703478d371ae0784a2cc8 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:40:48 -0400 Subject: [PATCH 4/5] fix: use the shell plugin namespace and type names in no-useless-echo Co-Authored-By: Claude Opus 5.5 --- docs/rules/no-useless-echo.md | 4 ++-- src/rules/no-useless-echo.spec.ts | 8 ++++---- src/rules/no-useless-echo.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rules/no-useless-echo.md b/docs/rules/no-useless-echo.md index 5010372..ac34600 100644 --- a/docs/rules/no-useless-echo.md +++ b/docs/rules/no-useless-echo.md @@ -17,7 +17,7 @@ Substitutions that do more than echo, such as pipelines (`$(echo "$name" | tr a- Examples of **incorrect** code for this rule: ```bash -# eslint bash/no-useless-echo: "error" +# eslint shell/no-useless-echo: "error" name=$(echo "$first") @@ -29,7 +29,7 @@ files=`echo "$dir"` Examples of **correct** code for this rule: ```bash -# eslint bash/no-useless-echo: "error" +# eslint shell/no-useless-echo: "error" name="$first" diff --git a/src/rules/no-useless-echo.spec.ts b/src/rules/no-useless-echo.spec.ts index 2f449f3..5e096d8 100644 --- a/src/rules/no-useless-echo.spec.ts +++ b/src/rules/no-useless-echo.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 "./no-useless-echo.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("no-useless-echo", rule as never, { diff --git a/src/rules/no-useless-echo.ts b/src/rules/no-useless-echo.ts index 7a49a68..8de0675 100644 --- a/src/rules/no-useless-echo.ts +++ b/src/rules/no-useless-echo.ts @@ -4,9 +4,9 @@ */ import { isCommandNamed } from "./utils.js"; -import type { BashRuleDefinition } from "../types.js"; +import type { ShellRuleDefinition } from "../types.js"; -const rule: BashRuleDefinition<{ MessageIds: "uselessEcho" }> = { +const rule: ShellRuleDefinition<{ MessageIds: "uselessEcho" }> = { meta: { type: "suggestion", languages: ["shell/bash"], @@ -14,7 +14,7 @@ const rule: BashRuleDefinition<{ MessageIds: "uselessEcho" }> = { description: "Disallow useless `echo` inside command substitutions", recommended: true, dialects: ["Bash", "POSIX sh", "mksh"], - url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-echo.md", + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-echo.md", }, schema: [], messages: { From 29df217a353b1b6feea636c6066d3fd31728cd35 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:42:48 -0400 Subject: [PATCH 5/5] fix: allow no-useless-echo 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/no-useless-echo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/no-useless-echo.ts b/src/rules/no-useless-echo.ts index 8de0675..d870194 100644 --- a/src/rules/no-useless-echo.ts +++ b/src/rules/no-useless-echo.ts @@ -9,7 +9,7 @@ import type { ShellRuleDefinition } from "../types.js"; const rule: ShellRuleDefinition<{ MessageIds: "uselessEcho" }> = { meta: { type: "suggestion", - languages: ["shell/bash"], + languages: ["shell/bash", "shell/posix", "shell/mksh"], docs: { description: "Disallow useless `echo` inside command substitutions", recommended: true,