From 63f29e89299a0a2284c16540a2f7318985d38f31 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 15:00:18 -0400 Subject: [PATCH 1/5] feat: add require-cd-guard rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/require-cd-guard.md | 62 +++++++++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 ++ src/rules/require-cd-guard.spec.ts | 76 ++++++++++++++++++++++++++ src/rules/require-cd-guard.ts | 86 ++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+) create mode 100644 docs/rules/require-cd-guard.md create mode 100644 src/rules/require-cd-guard.spec.ts create mode 100644 src/rules/require-cd-guard.ts diff --git a/README.md b/README.md index 834b11e..bcb9ddc 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-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 | ## Configuration comments diff --git a/docs/rules/require-cd-guard.md b/docs/rules/require-cd-guard.md new file mode 100644 index 0000000..e6a3f73 --- /dev/null +++ b/docs/rules/require-cd-guard.md @@ -0,0 +1,62 @@ +# require-cd-guard + +Require `cd` failures to be handled, e.g. `cd ... || exit`. + +## Background + +If `cd` fails, for example because the directory doesn't exist, the script keeps running in the directory it was already in. Every command after that runs in the wrong place, which can be destructive: `cd "$build_dir"; rm -rf *` deletes the wrong files when the `cd` fails. Checking the result of `cd` stops the script before that happens. + +## Rule Details + +This rule warns about `cd` commands whose exit status isn't checked. The status counts as checked when `cd` is: + +- the left side of `||` or `&&`, as in `cd dir || exit` or `cd dir && make`; +- the condition of an `if`, `while`, or `until`; +- negated with `!`. + +A `cd` on the right side of `&&` is still reported, because its own failure isn't handled. + +The rule doesn't take `set -e` into account, so scripts that rely on it still get warnings. It also reports `cd` inside subshells, such as `(cd dir; make)`. + +This rule provides a suggestion, not an autofix, to append `|| exit`. It's a suggestion because `return` is often the better choice inside functions. The suggestion isn't offered when the `cd` has redirects or runs in the background. + +Examples of **incorrect** code for this rule: + +```bash +# eslint bash/require-cd-guard: "error" + +cd /tmp + +cd "$build_dir" +make + +test -d build && cd build + +(cd src; make) +``` + +Examples of **correct** code for this rule: + +```bash +# eslint bash/require-cd-guard: "error" + +cd /tmp || exit + +cd "$build_dir" || return 1 + +cd build && make + +if cd build; then make; fi +``` + +## Options + +This rule has no options. + +## When Not to Use It + +If your scripts use `set -e` so that a failed `cd` already stops execution, and you rely on that behavior, you can disable this rule. + +## Prior Art + +- [SC2164](https://www.shellcheck.net/wiki/SC2164) diff --git a/src/index.spec.ts b/src/index.spec.ts index 93b87ab..2bdd813 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", + "require-cd-guard", "require-read-r", ]); }); diff --git a/src/index.ts b/src/index.ts index fd28790..9a425e8 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 requireCdGuard from "./rules/require-cd-guard.js"; import requireReadR from "./rules/require-read-r.js"; const rules = { @@ -19,6 +20,7 @@ const rules = { "no-unquoted-expansions": noUnquotedExpansions, "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, + "require-cd-guard": requireCdGuard, "require-read-r": requireReadR, }; @@ -47,6 +49,7 @@ const plugin = { "shell/no-unquoted-expansions": "error", "shell/no-useless-cat": "error", "shell/no-useless-echo": "error", + "shell/require-cd-guard": "error", "shell/require-read-r": "error", }, }, diff --git a/src/rules/require-cd-guard.spec.ts b/src/rules/require-cd-guard.spec.ts new file mode 100644 index 0000000..8e2dd25 --- /dev/null +++ b/src/rules/require-cd-guard.spec.ts @@ -0,0 +1,76 @@ +/** + * @fileoverview Tests for the require-cd-guard rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./require-cd-guard.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-cd-guard", rule as never, { + valid: [ + "cd /tmp || exit", + "cd /tmp || return", + "cd /tmp && make", + "if cd /tmp; then make; fi", + "while cd /tmp; do break; done", + "! cd /tmp", + // Not cd + "cdx /tmp", + ], + invalid: [ + { + code: "cd /tmp", + errors: [ + { + messageId: "uncheckedCd", + suggestions: [ + { + messageId: "addGuard", + output: "cd /tmp || exit", + }, + ], + }, + ], + }, + { + code: 'cd "$dir"\nmake', + errors: [ + { + messageId: "uncheckedCd", + line: 1, + suggestions: [ + { + messageId: "addGuard", + output: 'cd "$dir" || exit\nmake', + }, + ], + }, + ], + }, + { + // Right side of && is still unchecked + code: "test -d /tmp && cd /tmp", + errors: [ + { + messageId: "uncheckedCd", + suggestions: [ + { + messageId: "addGuard", + output: "test -d /tmp && cd /tmp || exit", + }, + ], + }, + ], + }, + ], +}); diff --git a/src/rules/require-cd-guard.ts b/src/rules/require-cd-guard.ts new file mode 100644 index 0000000..c5a2a59 --- /dev/null +++ b/src/rules/require-cd-guard.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview Rule to require handling `cd` failure. + * Mirrors ShellCheck SC2164. + */ + +import { getCommandName } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ + MessageIds: "uncheckedCd" | "addGuard"; +}> = { + meta: { + type: "problem", + docs: { + description: + "Require `cd` failures to be handled, e.g. `cd ... || exit`", + recommended: true, + url: "https://github.com/eslint/bash/blob/main/docs/rules/require-cd-guard.md", + }, + hasSuggestions: true, + schema: [], + messages: { + uncheckedCd: + "Use 'cd ... || exit' or 'cd ... || return' in case cd fails. (ShellCheck SC2164)", + addGuard: "Add '|| exit' after the cd command.", + }, + }, + + create(context) { + const { sourceCode } = context; + + return { + Command(node) { + if (getCommandName(node) !== "cd") { + return; + } + + // `! cd ...` uses the exit status explicitly. + if (node.negated) { + return; + } + + const parent = sourceCode.getParent(node); + + if (parent) { + // The left side of `&&`/`||` has its status checked. + if ( + parent.type === "LogicalExpression" && + parent.left === node + ) { + return; + } + + // `if cd ...`, `while cd ...`, `until cd ...` + if ( + (parent.type === "IfStatement" || + parent.type === "WhileStatement" || + parent.type === "UntilStatement") && + parent.test.includes(node) + ) { + return; + } + } + + const canSuggest = + !node.background && node.redirects.length === 0; + + context.report({ + node, + messageId: "uncheckedCd", + suggest: canSuggest + ? [ + { + messageId: "addGuard", + fix: fixer => + fixer.insertTextAfter(node, " || exit"), + }, + ] + : [], + }); + }, + }; + }, +}; + +export default rule; From ac9a0915860cbee8813c599a772ec188e2fabb84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:49:40 +0000 Subject: [PATCH 2/5] fix: detect cd guards in logical chains Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/require-cd-guard.spec.ts | 2 ++ src/rules/require-cd-guard.ts | 20 ++++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/rules/require-cd-guard.spec.ts b/src/rules/require-cd-guard.spec.ts index 8e2dd25..5e162e0 100644 --- a/src/rules/require-cd-guard.spec.ts +++ b/src/rules/require-cd-guard.spec.ts @@ -21,6 +21,8 @@ ruleTester.run("require-cd-guard", rule as never, { "cd /tmp || exit", "cd /tmp || return", "cd /tmp && make", + "precheck && cd /tmp && make", + "precheck || cd /tmp || make", "if cd /tmp; then make; fi", "while cd /tmp; do break; done", "! cd /tmp", diff --git a/src/rules/require-cd-guard.ts b/src/rules/require-cd-guard.ts index c5a2a59..4c3f638 100644 --- a/src/rules/require-cd-guard.ts +++ b/src/rules/require-cd-guard.ts @@ -4,7 +4,7 @@ */ import { getCommandName } from "./utils.js"; -import type { BashRuleDefinition } from "../types.js"; +import type { BashRuleDefinition, StatementNode } from "../types.js"; const rule: BashRuleDefinition<{ MessageIds: "uncheckedCd" | "addGuard"; @@ -40,17 +40,21 @@ const rule: BashRuleDefinition<{ return; } - const parent = sourceCode.getParent(node); + let expression: StatementNode = node; + let parent = sourceCode.getParent(expression); - if (parent) { - // The left side of `&&`/`||` has its status checked. - if ( - parent.type === "LogicalExpression" && - parent.left === node - ) { + while (parent?.type === "LogicalExpression") { + // A failed command can propagate through a left-associative + // chain to an expression whose status is checked. + if (parent.left === expression) { return; } + expression = parent; + parent = sourceCode.getParent(expression); + } + + if (parent) { // `if cd ...`, `while cd ...`, `until cd ...` if ( (parent.type === "IfStatement" || From 0bf63b6993248b4601a79592f2ebbf1ea2f39ba2 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:53 -0400 Subject: [PATCH 3/5] fix: add languages and dialects to require-cd-guard meta Co-Authored-By: Claude Opus 5.5 --- src/rules/require-cd-guard.spec.ts | 1 + src/rules/require-cd-guard.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/require-cd-guard.spec.ts b/src/rules/require-cd-guard.spec.ts index 5e162e0..77f0272 100644 --- a/src/rules/require-cd-guard.spec.ts +++ b/src/rules/require-cd-guard.spec.ts @@ -9,6 +9,7 @@ import rule from "./require-cd-guard.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-cd-guard.ts b/src/rules/require-cd-guard.ts index 4c3f638..096d55b 100644 --- a/src/rules/require-cd-guard.ts +++ b/src/rules/require-cd-guard.ts @@ -11,10 +11,12 @@ const rule: BashRuleDefinition<{ }> = { meta: { type: "problem", + languages: ["shell/bash"], docs: { description: "Require `cd` failures to be handled, e.g. `cd ... || exit`", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/require-cd-guard.md", }, hasSuggestions: true, From 3c8ace4c9213d00c8bcb4ad3b2ce9611326f5ffc Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:41:25 -0400 Subject: [PATCH 4/5] fix: use the shell plugin namespace and type names in require-cd-guard Co-Authored-By: Claude Opus 5.5 --- docs/rules/require-cd-guard.md | 4 ++-- src/rules/require-cd-guard.spec.ts | 8 ++++---- src/rules/require-cd-guard.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rules/require-cd-guard.md b/docs/rules/require-cd-guard.md index e6a3f73..31ae2f1 100644 --- a/docs/rules/require-cd-guard.md +++ b/docs/rules/require-cd-guard.md @@ -23,7 +23,7 @@ This rule provides a suggestion, not an autofix, to append `|| exit`. It's a sug Examples of **incorrect** code for this rule: ```bash -# eslint bash/require-cd-guard: "error" +# eslint shell/require-cd-guard: "error" cd /tmp @@ -38,7 +38,7 @@ test -d build && cd build Examples of **correct** code for this rule: ```bash -# eslint bash/require-cd-guard: "error" +# eslint shell/require-cd-guard: "error" cd /tmp || exit diff --git a/src/rules/require-cd-guard.spec.ts b/src/rules/require-cd-guard.spec.ts index 77f0272..edae35a 100644 --- a/src/rules/require-cd-guard.spec.ts +++ b/src/rules/require-cd-guard.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-cd-guard.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-cd-guard", rule as never, { diff --git a/src/rules/require-cd-guard.ts b/src/rules/require-cd-guard.ts index 096d55b..163c685 100644 --- a/src/rules/require-cd-guard.ts +++ b/src/rules/require-cd-guard.ts @@ -4,9 +4,9 @@ */ import { getCommandName } from "./utils.js"; -import type { BashRuleDefinition, StatementNode } from "../types.js"; +import type { ShellRuleDefinition, StatementNode } from "../types.js"; -const rule: BashRuleDefinition<{ +const rule: ShellRuleDefinition<{ MessageIds: "uncheckedCd" | "addGuard"; }> = { meta: { @@ -17,7 +17,7 @@ const rule: BashRuleDefinition<{ "Require `cd` failures to be handled, e.g. `cd ... || exit`", recommended: true, dialects: ["Bash", "POSIX sh", "mksh"], - url: "https://github.com/eslint/bash/blob/main/docs/rules/require-cd-guard.md", + url: "https://github.com/eslint/shell/blob/main/docs/rules/require-cd-guard.md", }, hasSuggestions: true, schema: [], From fa0e219b80d42d3d89b62845aba7d374797fc54f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:42:54 -0400 Subject: [PATCH 5/5] fix: allow require-cd-guard 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-cd-guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/require-cd-guard.ts b/src/rules/require-cd-guard.ts index 163c685..cd9572f 100644 --- a/src/rules/require-cd-guard.ts +++ b/src/rules/require-cd-guard.ts @@ -11,7 +11,7 @@ const rule: ShellRuleDefinition<{ }> = { meta: { type: "problem", - languages: ["shell/bash"], + languages: ["shell/bash", "shell/posix", "shell/mksh"], docs: { description: "Require `cd` failures to be handled, e.g. `cd ... || exit`",