From c968d1bcf3ea8016565160f9e968e5ada92feb14 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 14:59:47 -0400 Subject: [PATCH 1/5] feat: add no-useless-cat rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/no-useless-cat.md | 49 ++++++++++++++++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 ++ src/rules/no-useless-cat.spec.ts | 53 ++++++++++++++++++++++++++++++ src/rules/no-useless-cat.ts | 56 ++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 docs/rules/no-useless-cat.md create mode 100644 src/rules/no-useless-cat.spec.ts create mode 100644 src/rules/no-useless-cat.ts diff --git a/README.md b/README.md index 36ad35d..e21aaa7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Each rule mirrors a well-known ShellCheck check. | [`no-expansions-in-single-quotes`](./docs/rules/no-expansions-in-single-quotes.md) | SC2016 | Expressions don't expand in single quotes | | warn | | [`no-ls-iteration`](./docs/rules/no-ls-iteration.md) | SC2045 | Don't iterate over `ls` output; use globs | | error | | [`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 | ## Configuration comments diff --git a/docs/rules/no-useless-cat.md b/docs/rules/no-useless-cat.md new file mode 100644 index 0000000..2facd66 --- /dev/null +++ b/docs/rules/no-useless-cat.md @@ -0,0 +1,49 @@ +# no-useless-cat + +Disallow useless `cat` at the start of a pipeline. + +## Background + +`cat file | cmd` starts an extra process only to copy a file into a pipe. Most commands accept a filename argument (`cmd file`), and every command can read from a redirect (`cmd < file`). Either form is simpler and avoids the extra process. + +## Rule Details + +This rule warns when the first command of a pipeline is `cat` with exactly one filename argument and no redirects. + +The rule doesn't warn when `cat` actually does something, such as concatenating several files or using options like `-n`. It also skips arguments that aren't static text, such as `cat "$file"`. + +Examples of **incorrect** code for this rule: + +```bash +# eslint bash/no-useless-cat: "error" + +cat file.txt | grep error + +cat /var/log/syslog | tail -n 20 | grep kernel +``` + +Examples of **correct** code for this rule: + +```bash +# eslint bash/no-useless-cat: "error" + +grep error file.txt + +grep error < file.txt + +cat header.txt body.txt | gzip > out.gz + +cat -n file.txt | head +``` + +## Options + +This rule has no options. + +## When Not to Use It + +Some people prefer `cat file | cmd` because the data reads left to right. If you prefer that style, you can safely disable this rule. + +## Prior Art + +- [SC2002](https://www.shellcheck.net/wiki/SC2002) diff --git a/src/index.spec.ts b/src/index.spec.ts index f8167b2..347bc7f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -39,6 +39,7 @@ describe("plugin", () => { "no-expansions-in-single-quotes", "no-ls-iteration", "no-unquoted-expansions", + "no-useless-cat", "no-useless-echo", ]); }); diff --git a/src/index.ts b/src/index.ts index c71998b..f3c260e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import noBackticks from "./rules/no-backticks.js"; import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js"; 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"; const rules = { @@ -15,6 +16,7 @@ const rules = { "no-expansions-in-single-quotes": noExpansionsInSingleQuotes, "no-ls-iteration": noLsIteration, "no-unquoted-expansions": noUnquotedExpansions, + "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, }; @@ -41,6 +43,7 @@ const plugin = { "shell/no-expansions-in-single-quotes": "warn", "shell/no-ls-iteration": "error", "shell/no-unquoted-expansions": "error", + "shell/no-useless-cat": "error", "shell/no-useless-echo": "error", }, }, diff --git a/src/rules/no-useless-cat.spec.ts b/src/rules/no-useless-cat.spec.ts new file mode 100644 index 0000000..6597ae8 --- /dev/null +++ b/src/rules/no-useless-cat.spec.ts @@ -0,0 +1,53 @@ +/** + * @fileoverview Tests for the no-useless-cat rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./no-useless-cat.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-cat", rule as never, { + valid: [ + // No pipeline at all + "cat file.txt", + // cat merging multiple files is legitimate + "cat a.txt b.txt | sort", + // cat with flags changes output + "cat -n file.txt | head", + // pipeline not starting with cat + "sort file.txt | uniq | cat", + // cat with redirect input + "cat < file.txt | sort", + // dynamic file name still counts as one file, but command may + // legitimately be built dynamically; still reported? No: only + // static single-file cats are reported. + 'cat "$f" | sort', + ], + invalid: [ + { + code: "cat file.txt | grep foo", + errors: [ + { + messageId: "uselessCat", + line: 1, + column: 1, + endColumn: 13, + }, + ], + }, + { + code: "cat /var/log/syslog | tail -n 5 | grep error", + errors: [{ messageId: "uselessCat" }], + }, + ], +}); diff --git a/src/rules/no-useless-cat.ts b/src/rules/no-useless-cat.ts new file mode 100644 index 0000000..fbac192 --- /dev/null +++ b/src/rules/no-useless-cat.ts @@ -0,0 +1,56 @@ +/** + * @fileoverview Rule to disallow piping from a single-file `cat`. + * Mirrors ShellCheck SC2002. + */ + +import { getCommandName, getStaticText } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { + meta: { + type: "suggestion", + docs: { + description: "Disallow useless `cat` at the start of a pipeline", + recommended: true, + url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-cat.md", + }, + schema: [], + messages: { + uselessCat: + "Useless cat. Consider 'cmd < file' or 'cmd file' instead. (ShellCheck SC2002)", + }, + }, + + create(context) { + return { + Pipeline(node) { + const first = node.commands[0]; + + if ( + !first || + first.type !== "Command" || + getCommandName(first) !== "cat" || + first.redirects.length > 0 || + first.arguments.length !== 1 + ) { + return; + } + + const argument = first.arguments[0]; + const text = argument ? getStaticText(argument) : null; + + // Skip flags (e.g. `cat -n file`) and stdin markers. + if (text === null || text.startsWith("-")) { + return; + } + + context.report({ + node: first, + messageId: "uselessCat", + }); + }, + }; + }, +}; + +export default rule; From 3d400435e866d587c0357cb7cde5c99c26520072 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:45 +0000 Subject: [PATCH 2/5] fix: avoid no-useless-cat expansion false positives Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/no-useless-cat.spec.ts | 15 +++++++++++++ src/rules/no-useless-cat.ts | 37 +++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/rules/no-useless-cat.spec.ts b/src/rules/no-useless-cat.spec.ts index 6597ae8..dcb0d18 100644 --- a/src/rules/no-useless-cat.spec.ts +++ b/src/rules/no-useless-cat.spec.ts @@ -24,6 +24,13 @@ ruleTester.run("no-useless-cat", rule as never, { "cat a.txt b.txt | sort", // cat with flags changes output "cat -n file.txt | head", + // Unquoted glob and brace patterns can expand to multiple files. + "cat *.txt | sort", + "cat {a,b} | sort", + "cat {1..3} | sort", + "cat {a,{b}} | sort", + // ANSI-C quoting can transform source text into an option. + "cat $'\\x2dn' | head", // pipeline not starting with cat "sort file.txt | uniq | cat", // cat with redirect input @@ -49,5 +56,13 @@ ruleTester.run("no-useless-cat", rule as never, { code: "cat /var/log/syslog | tail -n 5 | grep error", errors: [{ messageId: "uselessCat" }], }, + { + code: 'cat "*.txt" | sort', + errors: [{ messageId: "uselessCat" }], + }, + { + code: "cat '{a,b}' | sort", + errors: [{ messageId: "uselessCat" }], + }, ], }); diff --git a/src/rules/no-useless-cat.ts b/src/rules/no-useless-cat.ts index fbac192..b990882 100644 --- a/src/rules/no-useless-cat.ts +++ b/src/rules/no-useless-cat.ts @@ -4,7 +4,32 @@ */ import { getCommandName, getStaticText } from "./utils.js"; -import type { BashRuleDefinition } from "../types.js"; +import type { BashRuleDefinition, WordNode, WordPartNode } from "../types.js"; + +function hasPotentialExpansion(word: WordNode): boolean { + const checkParts = (parts: WordPartNode[], quoted = false): boolean => + parts.some(part => { + switch (part.type) { + case "Literal": + return ( + !quoted && + (part.value.includes("*") || + part.value.includes("?") || + part.value.includes("[") || + part.value.includes("{") || + part.value.includes("}")) + ); + case "SingleQuotedString": + return part.dollar; + case "DoubleQuotedString": + return checkParts(part.parts, true); + default: + return false; + } + }); + + return checkParts(word.parts); +} const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { meta: { @@ -39,8 +64,14 @@ const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { const argument = first.arguments[0]; const text = argument ? getStaticText(argument) : null; - // Skip flags (e.g. `cat -n file`) and stdin markers. - if (text === null || text.startsWith("-")) { + // Skip flags, stdin markers, and arguments that can expand to + // multiple words. + if ( + text === null || + text.startsWith("-") || + !argument || + hasPotentialExpansion(argument) + ) { return; } From 36fbf48bb0c0dca84b3d8fdd6fd6c826fcce9699 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:48 -0400 Subject: [PATCH 3/5] fix: add languages and dialects to no-useless-cat meta Co-Authored-By: Claude Opus 5.5 --- src/rules/no-useless-cat.spec.ts | 1 + src/rules/no-useless-cat.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/no-useless-cat.spec.ts b/src/rules/no-useless-cat.spec.ts index dcb0d18..12588af 100644 --- a/src/rules/no-useless-cat.spec.ts +++ b/src/rules/no-useless-cat.spec.ts @@ -9,6 +9,7 @@ import rule from "./no-useless-cat.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-cat.ts b/src/rules/no-useless-cat.ts index b990882..274d1b4 100644 --- a/src/rules/no-useless-cat.ts +++ b/src/rules/no-useless-cat.ts @@ -34,9 +34,11 @@ function hasPotentialExpansion(word: WordNode): boolean { const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { meta: { type: "suggestion", + languages: ["shell/bash"], docs: { description: "Disallow useless `cat` at the start of a pipeline", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-cat.md", }, schema: [], From 235b2f2a9dbb3890358102402e7d53832bfcb586 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:41:08 -0400 Subject: [PATCH 4/5] fix: use the shell plugin namespace and type names in no-useless-cat Co-Authored-By: Claude Opus 5.5 --- docs/rules/no-useless-cat.md | 4 ++-- src/rules/no-useless-cat.spec.ts | 8 ++++---- src/rules/no-useless-cat.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rules/no-useless-cat.md b/docs/rules/no-useless-cat.md index 2facd66..2f43290 100644 --- a/docs/rules/no-useless-cat.md +++ b/docs/rules/no-useless-cat.md @@ -15,7 +15,7 @@ The rule doesn't warn when `cat` actually does something, such as concatenating Examples of **incorrect** code for this rule: ```bash -# eslint bash/no-useless-cat: "error" +# eslint shell/no-useless-cat: "error" cat file.txt | grep error @@ -25,7 +25,7 @@ cat /var/log/syslog | tail -n 20 | grep kernel Examples of **correct** code for this rule: ```bash -# eslint bash/no-useless-cat: "error" +# eslint shell/no-useless-cat: "error" grep error file.txt diff --git a/src/rules/no-useless-cat.spec.ts b/src/rules/no-useless-cat.spec.ts index 12588af..ef72313 100644 --- a/src/rules/no-useless-cat.spec.ts +++ b/src/rules/no-useless-cat.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-cat.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-cat", rule as never, { diff --git a/src/rules/no-useless-cat.ts b/src/rules/no-useless-cat.ts index 274d1b4..17573fa 100644 --- a/src/rules/no-useless-cat.ts +++ b/src/rules/no-useless-cat.ts @@ -4,7 +4,7 @@ */ import { getCommandName, getStaticText } from "./utils.js"; -import type { BashRuleDefinition, WordNode, WordPartNode } from "../types.js"; +import type { ShellRuleDefinition, WordNode, WordPartNode } from "../types.js"; function hasPotentialExpansion(word: WordNode): boolean { const checkParts = (parts: WordPartNode[], quoted = false): boolean => @@ -31,7 +31,7 @@ function hasPotentialExpansion(word: WordNode): boolean { return checkParts(word.parts); } -const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { +const rule: ShellRuleDefinition<{ MessageIds: "uselessCat" }> = { meta: { type: "suggestion", languages: ["shell/bash"], @@ -39,7 +39,7 @@ const rule: BashRuleDefinition<{ MessageIds: "uselessCat" }> = { description: "Disallow useless `cat` at the start of a pipeline", recommended: true, dialects: ["Bash", "POSIX sh", "mksh"], - url: "https://github.com/eslint/bash/blob/main/docs/rules/no-useless-cat.md", + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-cat.md", }, schema: [], messages: { From f62c9b802177fb50e350b4e83c1015ea7f8e8149 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:42:51 -0400 Subject: [PATCH 5/5] fix: allow no-useless-cat 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-cat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/no-useless-cat.ts b/src/rules/no-useless-cat.ts index 17573fa..0d3d01a 100644 --- a/src/rules/no-useless-cat.ts +++ b/src/rules/no-useless-cat.ts @@ -34,7 +34,7 @@ function hasPotentialExpansion(word: WordNode): boolean { const rule: ShellRuleDefinition<{ MessageIds: "uselessCat" }> = { meta: { type: "suggestion", - languages: ["shell/bash"], + languages: ["shell/bash", "shell/posix", "shell/mksh"], docs: { description: "Disallow useless `cat` at the start of a pipeline", recommended: true,