From 8439add6621db5523135ac225d1ac48d4e077110 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 15:00:35 -0400 Subject: [PATCH 1/5] feat: add no-variables-in-printf-format rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/no-variables-in-printf-format.md | 53 +++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 + .../no-variables-in-printf-format.spec.ts | 57 +++++++++++++++++ src/rules/no-variables-in-printf-format.ts | 64 +++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 docs/rules/no-variables-in-printf-format.md create mode 100644 src/rules/no-variables-in-printf-format.spec.ts create mode 100644 src/rules/no-variables-in-printf-format.ts diff --git a/README.md b/README.md index bcb9ddc..f4faf13 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 | +| [`no-variables-in-printf-format`](./docs/rules/no-variables-in-printf-format.md) | SC2059 | Don't put variables in the `printf` format string | | error | | [`require-cd-guard`](./docs/rules/require-cd-guard.md) | SC2164 | Handle `cd` failure with `\|\| exit` (has suggestions) | | error | | [`require-read-r`](./docs/rules/require-read-r.md) | SC2162 | Use `read -r` so backslashes aren't mangled | ✅ | error | diff --git a/docs/rules/no-variables-in-printf-format.md b/docs/rules/no-variables-in-printf-format.md new file mode 100644 index 0000000..d313941 --- /dev/null +++ b/docs/rules/no-variables-in-printf-format.md @@ -0,0 +1,53 @@ +# no-variables-in-printf-format + +Disallow variables in the printf format string; use %s placeholders instead. + +## Background + +The first argument to `printf` is a format string: `%` starts a conversion and `\` starts an escape sequence. When a variable is part of the format, any `%` or `\` in its value is interpreted too. A value such as `100% done` produces garbled output or an error. Passing the value as a separate argument with a `%s` placeholder prints it exactly. + +## Rule Details + +This rule warns when the format argument of `printf` contains a parameter expansion or command substitution, whether or not it's quoted. The format is the first argument, or the argument after `-v name`, `-vname`, or `--`. + +Variables in the arguments after the format are fine. Arithmetic expansions such as `$((...))` in the format are not checked. + +Examples of **incorrect** code for this rule: + +```bash +# eslint bash/no-variables-in-printf-format: "error" + +printf "$message" + +printf "Hello, $name\n" + +printf -v line "$format" "$value" + +printf "Files: $(ls | wc -l)\n" +``` + +Examples of **correct** code for this rule: + +```bash +# eslint bash/no-variables-in-printf-format: "error" + +printf '%s\n' "$message" + +printf 'Hello, %s\n' "$name" + +printf -v line '%s' "$value" + +printf 'Files: %d\n' "$(ls | wc -l)" +``` + +## Options + +This rule has no options. + +## When Not to Use It + +Keeping a format in a variable is sometimes intentional, such as `fmt='%-10s %s\n'` reused across several `printf` calls. If the variable always holds a format you control, use a disable comment for those lines. + +## Prior Art + +- [SC2059](https://www.shellcheck.net/wiki/SC2059) diff --git a/src/index.spec.ts b/src/index.spec.ts index 2bdd813..50c4f1b 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -41,6 +41,7 @@ describe("plugin", () => { "no-unquoted-expansions", "no-useless-cat", "no-useless-echo", + "no-variables-in-printf-format", "require-cd-guard", "require-read-r", ]); diff --git a/src/index.ts b/src/index.ts index 9a425e8..c50dae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,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 noVariablesInPrintfFormat from "./rules/no-variables-in-printf-format.js"; import requireCdGuard from "./rules/require-cd-guard.js"; import requireReadR from "./rules/require-read-r.js"; @@ -20,6 +21,7 @@ const rules = { "no-unquoted-expansions": noUnquotedExpansions, "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, + "no-variables-in-printf-format": noVariablesInPrintfFormat, "require-cd-guard": requireCdGuard, "require-read-r": requireReadR, }; @@ -49,6 +51,7 @@ const plugin = { "shell/no-unquoted-expansions": "error", "shell/no-useless-cat": "error", "shell/no-useless-echo": "error", + "shell/no-variables-in-printf-format": "error", "shell/require-cd-guard": "error", "shell/require-read-r": "error", }, diff --git a/src/rules/no-variables-in-printf-format.spec.ts b/src/rules/no-variables-in-printf-format.spec.ts new file mode 100644 index 0000000..5446816 --- /dev/null +++ b/src/rules/no-variables-in-printf-format.spec.ts @@ -0,0 +1,57 @@ +/** + * @fileoverview Tests for the no-variables-in-printf-format rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./no-variables-in-printf-format.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-variables-in-printf-format", rule as never, { + valid: [ + 'printf "%s\\n" "$var"', + 'printf \'%s: %d\\n\' "$name" "$count"', + 'printf -v result "%s" "$var"', + 'printf "static text\\n"', + // Not printf + 'echo "$var"', + ], + invalid: [ + { + code: 'printf "$var"', + errors: [ + { + messageId: "variableInFormat", + line: 1, + column: 8, + endColumn: 14, + }, + ], + }, + { + code: 'printf "$var\\n"', + errors: [{ messageId: "variableInFormat" }], + }, + { + code: "printf $format arg", + errors: [{ messageId: "variableInFormat" }], + }, + { + code: 'printf -v out "$fmt" x', + errors: [{ messageId: "variableInFormat" }], + }, + { + code: 'printf "count: $(wc -l < file)\\n"', + errors: [{ messageId: "variableInFormat" }], + }, + ], +}); diff --git a/src/rules/no-variables-in-printf-format.ts b/src/rules/no-variables-in-printf-format.ts new file mode 100644 index 0000000..a63faf9 --- /dev/null +++ b/src/rules/no-variables-in-printf-format.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Rule to disallow variables in the printf format string. + * Mirrors ShellCheck SC2059. + */ + +import { getCommandName, getExpansions, getStaticText } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ MessageIds: "variableInFormat" }> = { + meta: { + type: "problem", + docs: { + description: + "Disallow variables in the printf format string; use %s placeholders instead", + recommended: true, + url: "https://github.com/eslint/bash/blob/main/docs/rules/no-variables-in-printf-format.md", + }, + schema: [], + messages: { + variableInFormat: + "Don't use variables in the printf format string. Use printf '...%s...' \"$foo\". (ShellCheck SC2059)", + }, + }, + + create(context) { + return { + Command(node) { + if (getCommandName(node) !== "printf") { + return; + } + + let formatIndex = 0; + const first = node.arguments[0] + ? getStaticText(node.arguments[0]) + : null; + + if (first === "-v") { + // `printf -v var format ...` + formatIndex = 2; + } else if (first !== null && first.startsWith("-v")) { + // `printf -vvar format ...` + formatIndex = 1; + } else if (first === "--") { + formatIndex = 1; + } + + const format = node.arguments[formatIndex]; + + if (!format) { + return; + } + + if (getExpansions(format, { includeQuoted: true }).length > 0) { + context.report({ + node: format, + messageId: "variableInFormat", + }); + } + }, + }; + }, +}; + +export default rule; From e9247d78a83e5445117ccaeb0c95e753851115f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:48:59 +0000 Subject: [PATCH 2/5] fix: skip optional -- after printf -v in format rule Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- docs/rules/no-variables-in-printf-format.md | 2 +- src/rules/no-variables-in-printf-format.spec.ts | 15 +++++++++++++++ src/rules/no-variables-in-printf-format.ts | 7 +++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/rules/no-variables-in-printf-format.md b/docs/rules/no-variables-in-printf-format.md index d313941..04ea907 100644 --- a/docs/rules/no-variables-in-printf-format.md +++ b/docs/rules/no-variables-in-printf-format.md @@ -8,7 +8,7 @@ The first argument to `printf` is a format string: `%` starts a conversion and ` ## Rule Details -This rule warns when the format argument of `printf` contains a parameter expansion or command substitution, whether or not it's quoted. The format is the first argument, or the argument after `-v name`, `-vname`, or `--`. +This rule warns when the format argument of `printf` contains a parameter expansion or command substitution, whether or not it's quoted. The format is the first argument after an optional `-v name` or `-vname` and an optional `--`. Variables in the arguments after the format are fine. Arithmetic expansions such as `$((...))` in the format are not checked. diff --git a/src/rules/no-variables-in-printf-format.spec.ts b/src/rules/no-variables-in-printf-format.spec.ts index 5446816..e398290 100644 --- a/src/rules/no-variables-in-printf-format.spec.ts +++ b/src/rules/no-variables-in-printf-format.spec.ts @@ -21,6 +21,9 @@ ruleTester.run("no-variables-in-printf-format", rule as never, { 'printf "%s\\n" "$var"', 'printf \'%s: %d\\n\' "$name" "$count"', 'printf -v result "%s" "$var"', + 'printf -v result -- "%s" "$var"', + 'printf -vresult -- "%s" "$var"', + 'printf -- "%s" "$var"', 'printf "static text\\n"', // Not printf 'echo "$var"', @@ -49,6 +52,18 @@ ruleTester.run("no-variables-in-printf-format", rule as never, { code: 'printf -v out "$fmt" x', errors: [{ messageId: "variableInFormat" }], }, + { + code: 'printf -v out -- "$fmt" x', + errors: [{ messageId: "variableInFormat" }], + }, + { + code: 'printf -vout -- "$fmt" x', + errors: [{ messageId: "variableInFormat" }], + }, + { + code: 'printf -- "$fmt" x', + errors: [{ messageId: "variableInFormat" }], + }, { code: 'printf "count: $(wc -l < file)\\n"', errors: [{ messageId: "variableInFormat" }], diff --git a/src/rules/no-variables-in-printf-format.ts b/src/rules/no-variables-in-printf-format.ts index a63faf9..9bee71c 100644 --- a/src/rules/no-variables-in-printf-format.ts +++ b/src/rules/no-variables-in-printf-format.ts @@ -40,8 +40,11 @@ const rule: BashRuleDefinition<{ MessageIds: "variableInFormat" }> = { } else if (first !== null && first.startsWith("-v")) { // `printf -vvar format ...` formatIndex = 1; - } else if (first === "--") { - formatIndex = 1; + } + + const possibleFormat = node.arguments[formatIndex]; + if (possibleFormat && getStaticText(possibleFormat) === "--") { + formatIndex++; } const format = node.arguments[formatIndex]; From 8a48078c940293ffb78960aca13262b3d22c76ba Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:55 -0400 Subject: [PATCH 3/5] fix: add languages and dialects to no-variables-in-printf-format meta Co-Authored-By: Claude Opus 5.5 --- src/rules/no-variables-in-printf-format.spec.ts | 1 + src/rules/no-variables-in-printf-format.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/no-variables-in-printf-format.spec.ts b/src/rules/no-variables-in-printf-format.spec.ts index e398290..2fc7955 100644 --- a/src/rules/no-variables-in-printf-format.spec.ts +++ b/src/rules/no-variables-in-printf-format.spec.ts @@ -9,6 +9,7 @@ import rule from "./no-variables-in-printf-format.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-variables-in-printf-format.ts b/src/rules/no-variables-in-printf-format.ts index 9bee71c..f8d79d2 100644 --- a/src/rules/no-variables-in-printf-format.ts +++ b/src/rules/no-variables-in-printf-format.ts @@ -9,10 +9,12 @@ import type { BashRuleDefinition } from "../types.js"; const rule: BashRuleDefinition<{ MessageIds: "variableInFormat" }> = { meta: { type: "problem", + languages: ["shell/bash"], docs: { description: "Disallow variables in the printf format string; use %s placeholders instead", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/no-variables-in-printf-format.md", }, schema: [], From e65d5bda35f762f54d26823f17a2a69b6bf6c4a0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:41:37 -0400 Subject: [PATCH 4/5] fix: use the shell plugin namespace and type names in no-variables-in-printf-format Co-Authored-By: Claude Opus 5.5 --- docs/rules/no-variables-in-printf-format.md | 4 ++-- src/rules/no-variables-in-printf-format.spec.ts | 8 ++++---- src/rules/no-variables-in-printf-format.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rules/no-variables-in-printf-format.md b/docs/rules/no-variables-in-printf-format.md index 04ea907..39a6861 100644 --- a/docs/rules/no-variables-in-printf-format.md +++ b/docs/rules/no-variables-in-printf-format.md @@ -15,7 +15,7 @@ Variables in the arguments after the format are fine. Arithmetic expansions such Examples of **incorrect** code for this rule: ```bash -# eslint bash/no-variables-in-printf-format: "error" +# eslint shell/no-variables-in-printf-format: "error" printf "$message" @@ -29,7 +29,7 @@ printf "Files: $(ls | wc -l)\n" Examples of **correct** code for this rule: ```bash -# eslint bash/no-variables-in-printf-format: "error" +# eslint shell/no-variables-in-printf-format: "error" printf '%s\n' "$message" diff --git a/src/rules/no-variables-in-printf-format.spec.ts b/src/rules/no-variables-in-printf-format.spec.ts index 2fc7955..c520cac 100644 --- a/src/rules/no-variables-in-printf-format.spec.ts +++ b/src/rules/no-variables-in-printf-format.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-variables-in-printf-format.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-variables-in-printf-format", rule as never, { diff --git a/src/rules/no-variables-in-printf-format.ts b/src/rules/no-variables-in-printf-format.ts index f8d79d2..fe766d2 100644 --- a/src/rules/no-variables-in-printf-format.ts +++ b/src/rules/no-variables-in-printf-format.ts @@ -4,9 +4,9 @@ */ import { getCommandName, getExpansions, getStaticText } from "./utils.js"; -import type { BashRuleDefinition } from "../types.js"; +import type { ShellRuleDefinition } from "../types.js"; -const rule: BashRuleDefinition<{ MessageIds: "variableInFormat" }> = { +const rule: ShellRuleDefinition<{ MessageIds: "variableInFormat" }> = { meta: { type: "problem", languages: ["shell/bash"], @@ -15,7 +15,7 @@ const rule: BashRuleDefinition<{ MessageIds: "variableInFormat" }> = { "Disallow variables in the printf format string; use %s placeholders instead", recommended: true, dialects: ["Bash", "POSIX sh", "mksh"], - url: "https://github.com/eslint/bash/blob/main/docs/rules/no-variables-in-printf-format.md", + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-variables-in-printf-format.md", }, schema: [], messages: { From 74b6423aaf64d5d64a583112b138f734b5d5c14d Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:42:56 -0400 Subject: [PATCH 5/5] fix: allow no-variables-in-printf-format 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-variables-in-printf-format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/no-variables-in-printf-format.ts b/src/rules/no-variables-in-printf-format.ts index fe766d2..26f7984 100644 --- a/src/rules/no-variables-in-printf-format.ts +++ b/src/rules/no-variables-in-printf-format.ts @@ -9,7 +9,7 @@ import type { ShellRuleDefinition } from "../types.js"; const rule: ShellRuleDefinition<{ MessageIds: "variableInFormat" }> = { meta: { type: "problem", - languages: ["shell/bash"], + languages: ["shell/bash", "shell/posix", "shell/mksh"], docs: { description: "Disallow variables in the printf format string; use %s placeholders instead",