@eslint-sh/eslint-plugin 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +228 -17
  2. package/dist/command-harvest/builtins/bash.d.ts +36 -0
  3. package/dist/command-harvest/builtins/bash.js +98 -0
  4. package/dist/command-harvest/builtins/bats.d.ts +45 -0
  5. package/dist/command-harvest/builtins/bats.js +33 -0
  6. package/dist/command-harvest/builtins/index.d.ts +18 -0
  7. package/dist/command-harvest/builtins/index.js +39 -0
  8. package/dist/command-harvest/builtins/posix.d.ts +51 -0
  9. package/dist/command-harvest/builtins/posix.js +93 -0
  10. package/dist/command-harvest/harvest-commands.d.ts +66 -0
  11. package/dist/command-harvest/harvest-commands.js +97 -0
  12. package/dist/command-harvest/wrapper-table.d.ts +24 -0
  13. package/dist/command-harvest/wrapper-table.js +49 -0
  14. package/dist/configs/agent-authored.d.ts +41 -0
  15. package/dist/configs/agent-authored.js +33 -0
  16. package/dist/configs/bash-3-2.d.ts +28 -0
  17. package/dist/configs/bash-3-2.js +51 -0
  18. package/dist/configs/posix-portable.d.ts +40 -0
  19. package/dist/configs/posix-portable.js +53 -0
  20. package/dist/configs/recommended.d.ts +52 -0
  21. package/dist/configs/recommended.js +34 -0
  22. package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
  23. package/dist/declaration-conventions/comment-pragma.js +153 -0
  24. package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
  25. package/dist/declaration-conventions/declaration-convention.js +1 -0
  26. package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
  27. package/dist/declaration-conventions/read-declared-commands.js +128 -0
  28. package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
  29. package/dist/eslint-plugin-syntax.d.ts +27 -0
  30. package/dist/eslint-plugin.d.ts +1211 -52
  31. package/dist/index.d.ts +56 -8
  32. package/dist/index.js +53 -6
  33. package/dist/language/command-resolution.d.ts +124 -0
  34. package/dist/language/command-resolution.js +120 -0
  35. package/dist/language/end-of-input-marker.d.ts +29 -0
  36. package/dist/language/end-of-input-marker.js +88 -0
  37. package/dist/language/line-breaks.d.ts +18 -0
  38. package/dist/language/line-breaks.js +22 -0
  39. package/dist/language/lint-view-types.d.ts +104 -0
  40. package/dist/language/lint-view-types.js +1 -0
  41. package/dist/language/non-shell-file-guard.d.ts +35 -0
  42. package/dist/language/non-shell-file-guard.js +59 -0
  43. package/dist/language/selector-classes.d.ts +0 -1
  44. package/dist/language/selector-classes.js +0 -1
  45. package/dist/language/shell-language-options.d.ts +7 -1
  46. package/dist/language/shell-language-options.js +4 -4
  47. package/dist/language/shell-language.d.ts +10 -2
  48. package/dist/language/shell-language.js +59 -8
  49. package/dist/language/shell-source-code.d.ts +342 -5
  50. package/dist/language/shell-source-code.js +559 -30
  51. package/dist/language/source-coordinates.d.ts +13 -2
  52. package/dist/language/source-coordinates.js +68 -14
  53. package/dist/language/syntax-view.d.ts +60 -39
  54. package/dist/language/syntax-view.js +200 -45
  55. package/dist/language/word-resolution.d.ts +4 -3
  56. package/dist/language/word-resolution.js +4 -3
  57. package/dist/rules/compat/create-compat-rule.d.ts +59 -0
  58. package/dist/rules/compat/create-compat-rule.js +65 -0
  59. package/dist/rules/compat/declaration-options.d.ts +19 -0
  60. package/dist/rules/compat/declaration-options.js +110 -0
  61. package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
  62. package/dist/rules/compat/no-append-all-redirect.js +73 -0
  63. package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
  64. package/dist/rules/compat/no-associative-arrays.js +39 -0
  65. package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
  66. package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
  67. package/dist/rules/compat/no-case-attributes.d.ts +12 -0
  68. package/dist/rules/compat/no-case-attributes.js +63 -0
  69. package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
  70. package/dist/rules/compat/no-case-fallthrough.js +67 -0
  71. package/dist/rules/compat/no-case-modification.d.ts +35 -0
  72. package/dist/rules/compat/no-case-modification.js +86 -0
  73. package/dist/rules/compat/no-coproc.d.ts +12 -0
  74. package/dist/rules/compat/no-coproc.js +47 -0
  75. package/dist/rules/compat/no-declare-g.d.ts +12 -0
  76. package/dist/rules/compat/no-declare-g.js +59 -0
  77. package/dist/rules/compat/no-double-bracket.d.ts +12 -0
  78. package/dist/rules/compat/no-double-bracket.js +52 -0
  79. package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
  80. package/dist/rules/compat/no-epoch-variables.js +57 -0
  81. package/dist/rules/compat/no-fd-variable.d.ts +12 -0
  82. package/dist/rules/compat/no-fd-variable.js +48 -0
  83. package/dist/rules/compat/no-globstar.d.ts +12 -0
  84. package/dist/rules/compat/no-globstar.js +39 -0
  85. package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
  86. package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
  87. package/dist/rules/compat/no-let.d.ts +12 -0
  88. package/dist/rules/compat/no-let.js +62 -0
  89. package/dist/rules/compat/no-mapfile.d.ts +13 -0
  90. package/dist/rules/compat/no-mapfile.js +122 -0
  91. package/dist/rules/compat/no-nameref.d.ts +12 -0
  92. package/dist/rules/compat/no-nameref.js +44 -0
  93. package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
  94. package/dist/rules/compat/no-negative-array-index.js +123 -0
  95. package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
  96. package/dist/rules/compat/no-nounset-empty-array.js +158 -0
  97. package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
  98. package/dist/rules/compat/no-parameter-transformation.js +123 -0
  99. package/dist/rules/compat/no-pipe-all.d.ts +13 -0
  100. package/dist/rules/compat/no-pipe-all.js +68 -0
  101. package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
  102. package/dist/rules/compat/no-printf-time-format.js +99 -0
  103. package/dist/rules/compat/no-source.d.ts +13 -0
  104. package/dist/rules/compat/no-source.js +103 -0
  105. package/dist/rules/compat/no-test-v.d.ts +12 -0
  106. package/dist/rules/compat/no-test-v.js +280 -0
  107. package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
  108. package/dist/rules/compat/no-unicode-escape.js +120 -0
  109. package/dist/rules/compat/no-wait-n.d.ts +12 -0
  110. package/dist/rules/compat/no-wait-n.js +73 -0
  111. package/dist/rules/compat/operator-range.d.ts +10 -0
  112. package/dist/rules/compat/operator-range.js +15 -0
  113. package/dist/rules/compat/posix-absent-command.d.ts +12 -0
  114. package/dist/rules/compat/posix-absent-command.js +134 -0
  115. package/dist/rules/compat/shopt-options.d.ts +9 -0
  116. package/dist/rules/compat/shopt-options.js +63 -0
  117. package/dist/rules/compat/version-gates.d.ts +76 -0
  118. package/dist/rules/compat/version-gates.js +205 -0
  119. package/dist/rules/create-rule.d.ts +91 -0
  120. package/dist/rules/create-rule.js +51 -0
  121. package/dist/rules/create-wrapper-rule.d.ts +166 -0
  122. package/dist/rules/create-wrapper-rule.js +407 -0
  123. package/dist/rules/define-shell-rule.d.ts +184 -0
  124. package/dist/rules/define-shell-rule.js +56 -0
  125. package/dist/rules/index.d.ts +26 -0
  126. package/dist/rules/index.js +90 -0
  127. package/dist/rules/max-script-complexity.d.ts +28 -0
  128. package/dist/rules/max-script-complexity.js +130 -0
  129. package/dist/rules/no-dynamic-invocation.d.ts +35 -0
  130. package/dist/rules/no-dynamic-invocation.js +117 -0
  131. package/dist/rules/no-eval.d.ts +13 -0
  132. package/dist/rules/no-eval.js +89 -0
  133. package/dist/rules/no-masked-exit-status.d.ts +21 -0
  134. package/dist/rules/no-masked-exit-status.js +667 -0
  135. package/dist/rules/no-secret-env-reads.d.ts +24 -0
  136. package/dist/rules/no-secret-env-reads.js +133 -0
  137. package/dist/rules/no-secret-flags.d.ts +32 -0
  138. package/dist/rules/no-secret-flags.js +162 -0
  139. package/dist/rules/no-unchecked-cd.d.ts +44 -0
  140. package/dist/rules/no-unchecked-cd.js +250 -0
  141. package/dist/rules/no-undeclared-commands.d.ts +20 -0
  142. package/dist/rules/no-undeclared-commands.js +115 -0
  143. package/dist/rules/no-unused-declarations.d.ts +23 -0
  144. package/dist/rules/no-unused-declarations.js +259 -0
  145. package/dist/rules/prefer-local.d.ts +16 -0
  146. package/dist/rules/prefer-local.js +249 -0
  147. package/dist/rules/quote-expansions.d.ts +13 -0
  148. package/dist/rules/quote-expansions.js +229 -0
  149. package/dist/rules/require-network-wrapper.d.ts +40 -0
  150. package/dist/rules/require-network-wrapper.js +244 -0
  151. package/dist/rules/require-shebang.d.ts +29 -0
  152. package/dist/rules/require-shebang.js +101 -0
  153. package/dist/rules/require-strict-mode.d.ts +52 -0
  154. package/dist/rules/require-strict-mode.js +177 -0
  155. package/dist/rules/require-tty-guard.d.ts +13 -0
  156. package/dist/rules/require-tty-guard.js +318 -0
  157. package/dist/rules/resolve-declaration-convention.d.ts +96 -0
  158. package/dist/rules/resolve-declaration-convention.js +109 -0
  159. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  160. package/dist/rules/resolve-network-wrapper.js +116 -0
  161. package/dist/rules/set-options.d.ts +109 -0
  162. package/dist/rules/set-options.js +201 -0
  163. package/dist/rules/shebang-interpreter.d.ts +18 -0
  164. package/dist/rules/shebang-interpreter.js +81 -0
  165. package/dist/rules/shebang-matches-dialect.d.ts +12 -0
  166. package/dist/rules/shebang-matches-dialect.js +69 -0
  167. package/dist/rules/transparent-wrappers.d.ts +52 -0
  168. package/dist/rules/transparent-wrappers.js +99 -0
  169. package/dist/rules/user-pattern.d.ts +63 -0
  170. package/dist/rules/user-pattern.js +184 -0
  171. package/dist/syntax/analyze.d.ts +19 -0
  172. package/dist/syntax/analyze.js +21 -0
  173. package/dist/syntax/index.d.ts +23 -0
  174. package/dist/syntax/index.js +25 -0
  175. package/dist/version.d.ts +0 -1
  176. package/dist/version.js +0 -1
  177. package/docs/compat.md +354 -0
  178. package/docs/custom-rule-tutorial.md +780 -34
  179. package/docs/declaration-conventions.md +478 -0
  180. package/docs/examples/audit-wrapper-pack.mjs +15 -0
  181. package/docs/examples/dep-line-convention.mjs +22 -0
  182. package/docs/examples/eslint.config.mjs +3 -2
  183. package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
  184. package/docs/examples/no-backticks-rule-tester.mjs +106 -0
  185. package/docs/examples/no-backticks-rule.mjs +60 -0
  186. package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
  187. package/docs/examples/no-function-keyword-rule.mjs +59 -0
  188. package/docs/examples/no-listed-commands-rule.ts +63 -0
  189. package/docs/examples/no-rm-rf-rule.mjs +26 -15
  190. package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
  191. package/docs/examples/sidecar-convention.mjs +38 -0
  192. package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
  193. package/docs/examples/sorted-declarations-rule.mjs +52 -0
  194. package/docs/reading-nodes.md +159 -0
  195. package/docs/rules/README.md +79 -0
  196. package/docs/rules/_template.md +83 -0
  197. package/docs/rules/compat/no-append-all-redirect.md +87 -0
  198. package/docs/rules/compat/no-associative-arrays.md +95 -0
  199. package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
  200. package/docs/rules/compat/no-case-attributes.md +82 -0
  201. package/docs/rules/compat/no-case-fallthrough.md +90 -0
  202. package/docs/rules/compat/no-case-modification.md +74 -0
  203. package/docs/rules/compat/no-coproc.md +76 -0
  204. package/docs/rules/compat/no-declare-g.md +80 -0
  205. package/docs/rules/compat/no-double-bracket.md +72 -0
  206. package/docs/rules/compat/no-epoch-variables.md +79 -0
  207. package/docs/rules/compat/no-fd-variable.md +78 -0
  208. package/docs/rules/compat/no-globstar.md +74 -0
  209. package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
  210. package/docs/rules/compat/no-let.md +78 -0
  211. package/docs/rules/compat/no-mapfile.md +118 -0
  212. package/docs/rules/compat/no-nameref.md +81 -0
  213. package/docs/rules/compat/no-negative-array-index.md +87 -0
  214. package/docs/rules/compat/no-nounset-empty-array.md +103 -0
  215. package/docs/rules/compat/no-parameter-transformation.md +94 -0
  216. package/docs/rules/compat/no-pipe-all.md +85 -0
  217. package/docs/rules/compat/no-printf-time-format.md +75 -0
  218. package/docs/rules/compat/no-source.md +98 -0
  219. package/docs/rules/compat/no-test-v.md +82 -0
  220. package/docs/rules/compat/no-unicode-escape.md +91 -0
  221. package/docs/rules/compat/no-wait-n.md +85 -0
  222. package/docs/rules/max-script-complexity.md +109 -0
  223. package/docs/rules/no-dynamic-invocation.md +139 -0
  224. package/docs/rules/no-eval.md +106 -0
  225. package/docs/rules/no-masked-exit-status.md +239 -0
  226. package/docs/rules/no-secret-env-reads.md +173 -0
  227. package/docs/rules/no-secret-flags.md +169 -0
  228. package/docs/rules/no-unchecked-cd.md +146 -0
  229. package/docs/rules/no-undeclared-commands.md +141 -0
  230. package/docs/rules/no-unused-declarations.md +216 -0
  231. package/docs/rules/prefer-local.md +221 -0
  232. package/docs/rules/quote-expansions.md +157 -0
  233. package/docs/rules/require-network-wrapper.md +171 -0
  234. package/docs/rules/require-shebang.md +157 -0
  235. package/docs/rules/require-strict-mode.md +253 -0
  236. package/docs/rules/require-tty-guard.md +131 -0
  237. package/docs/rules/shebang-matches-dialect.md +120 -0
  238. package/docs/selectors.md +2 -2
  239. package/docs/shellcheck.md +87 -0
  240. package/docs/untrusted-code.md +440 -0
  241. package/docs/wrapper-rules.md +206 -0
  242. package/package.json +26 -20
  243. package/dist/index.d.ts.map +0 -1
  244. package/dist/index.js.map +0 -1
  245. package/dist/language/selector-classes.d.ts.map +0 -1
  246. package/dist/language/selector-classes.js.map +0 -1
  247. package/dist/language/shell-language-options.d.ts.map +0 -1
  248. package/dist/language/shell-language-options.js.map +0 -1
  249. package/dist/language/shell-language.d.ts.map +0 -1
  250. package/dist/language/shell-language.js.map +0 -1
  251. package/dist/language/shell-source-code.d.ts.map +0 -1
  252. package/dist/language/shell-source-code.js.map +0 -1
  253. package/dist/language/source-coordinates.d.ts.map +0 -1
  254. package/dist/language/source-coordinates.js.map +0 -1
  255. package/dist/language/syntax-view.d.ts.map +0 -1
  256. package/dist/language/syntax-view.js.map +0 -1
  257. package/dist/language/word-resolution.d.ts.map +0 -1
  258. package/dist/language/word-resolution.js.map +0 -1
  259. package/dist/version.d.ts.map +0 -1
  260. package/dist/version.js.map +0 -1
@@ -0,0 +1,109 @@
1
+ # `sh/max-script-complexity`
2
+
3
+ Cap how many functions, how many levels of nesting and how many statements a single script may
4
+ contain, so that a machine-written script stays reviewable in one sitting.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ---------------- | ----------- | --------- |
8
+ | `agent-authored` | not fixable | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ A script that grows past what a reviewer can hold in their head cannot be vouched for, however
13
+ clean each line is. This rule enforces three independent limits. Each limit is reported **at most
14
+ once per file**, under its own message, and is skipped when set to `null`. A limit of `0` is a real limit: it allows none of that construct, so the first one reports.
15
+
16
+ - **Function count** (`maxFunctions`, default `20`): the number of function declarations in the
17
+ file, at any depth. Reported at the file level (line 1), because no one declaration is to blame.
18
+ - **Nesting depth** (`maxNesting`, default `4`): every `if`, `for` (including `select`), `while`,
19
+ `until`, `case`, subshell `( … )` and brace group `{ …; }` adds one level. Reported at the first
20
+ construct whose depth is past the limit. An `elif` or `else` arm continues its `if` and adds no
21
+ depth. A function's own body braces are the function's syntax, not a brace group, and functions,
22
+ command substitutions and process substitutions add no depth.
23
+ - **Statement count** (`maxStatements`, default `300`): every statement anywhere in the file,
24
+ because the limit bounds the script's overall size. That includes top-level statements, those in
25
+ function bodies and in nested compound commands, and the statements in condition and header
26
+ lists: in `if a; then :; fi` the `if`, the `a` and the `:` are three statements, and likewise the
27
+ `a` in `while a; do …`. Statements inside command substitutions `$(…)` count too: in
28
+ `x=$(echo a; echo b)` the assignment and the two `echo` statements are three statements. The operands of `&&`, `||` and `|` are not counted separately, so one
29
+ pipeline or and-or list is one statement, and a function declaration is one statement plus those
30
+ in its body. Reported at the first statement past the limit.
31
+
32
+ The rule inspects syntax only and has no autofix or suggestion: splitting a script is a human
33
+ decision.
34
+
35
+ ## Options
36
+
37
+ An optional object; each field is a non-negative integer limit, `null` (switches that one limit off) or omitted (takes the default). `0` is a real limit, as in ESLint's `max-depth`, `max-params` and `max-statements`: `maxFunctions: 0` forbids function declarations.
38
+
39
+ - `maxFunctions` (default `20`): most function declarations allowed.
40
+ - `maxNesting` (default `4`): deepest control-structure nesting allowed.
41
+ - `maxStatements` (default `300`): most statements allowed.
42
+
43
+ ```js
44
+ // eslint.config.js
45
+ {
46
+ rules: {
47
+ 'sh/max-script-complexity': ['error', { maxFunctions: 10, maxNesting: 3, maxStatements: null }],
48
+ },
49
+ }
50
+ ```
51
+
52
+ ## Examples
53
+
54
+ ### Incorrect
55
+
56
+ With `{ maxNesting: 2 }`:
57
+
58
+ <!-- example: {"options": {"maxNesting": 2}, "findings": 1} -->
59
+ ```sh
60
+ if [ -f a ]; then
61
+ for f in *; do
62
+ while read -r line; do # reported: nested 3 levels deep
63
+ echo "$line"
64
+ done < "$f"
65
+ done
66
+ fi
67
+ ```
68
+
69
+ With `{ maxFunctions: 1 }`:
70
+
71
+ <!-- example: {"options": {"maxFunctions": 1}, "findings": 1} -->
72
+ ```sh
73
+ a() { :; }
74
+ b() { :; } # the file is reported on line 1: 2 functions
75
+ ```
76
+
77
+ ### Correct
78
+
79
+ With `{ maxNesting: 2 }`:
80
+
81
+ <!-- example: {"options": {"maxNesting": 2}} -->
82
+ ```sh
83
+ if [ -f a ]; then
84
+ echo one
85
+ elif [ -f b ]; then # an elif adds no depth
86
+ for f in *; do
87
+ echo "$f"
88
+ done
89
+ fi
90
+
91
+ run() { # a function's own braces add no depth
92
+ if true; then
93
+ echo ok
94
+ fi
95
+ }
96
+ ```
97
+
98
+ ## When not to use it
99
+
100
+ When your project does not hold a size policy for scripts, or when a generated script is
101
+ legitimately large. Disable one check by setting its limit to `null` rather than the whole rule.
102
+
103
+ ## Relationship to shellcheck
104
+
105
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
106
+ layer. Run shellcheck beside it.
107
+
108
+ shellcheck reports defects in individual commands and has no check that limits a script's size,
109
+ function count or nesting depth, so this rule overlaps none of its `SCxxxx` checks.
@@ -0,0 +1,139 @@
1
+ # `sh/no-dynamic-invocation`
2
+
3
+ Disallow commands whose executed command cannot be statically determined, so that every command
4
+ a script can run is readable from its source.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ------- | ------------ | --------- |
8
+ | `agent-authored` | not fixable | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ To vouch for a script, a reviewer has to know every command it can run. A command word whose
13
+ value is only known at run time defeats that: `"$cmd" args`, `${tool} x`, `$(pick-tool) x`, and
14
+ `"${cmd[@]}"` can each run anything. So can a string of shell code handed to another shell:
15
+ `bash -c "$x"` and `sh -c '…'` run code that is a string to the reader, not commands.
16
+
17
+ The rule checks every simple command in the file, wherever it appears: top level, functions,
18
+ loops, conditionals, pipelines, and command and process substitutions. So in
19
+ `echo "$("$cmd")"`, the inner `"$cmd"` reports.
20
+
21
+ For each command, the rule finds the command that actually runs by following _transparent
22
+ wrappers_: commands that run the word after them. `time ls` runs `ls`, and
23
+ `sudo -u deploy "$prog"` runs `"$prog"`. The wrappers followed are:
24
+
25
+ - `command`, `exec`, `env`, `nohup`, `nice`, `time`, `timeout`, and `sudo`, with their own
26
+ options skipped (`timeout 10 ls`, `env FOO=1 ls`). `command -v name` only looks `name` up, so
27
+ it runs nothing else.
28
+ - Bash's `builtin`.
29
+ - Shells invoked as commands: `bash`, `sh`, `dash`, `ksh`, `mksh`, and `zsh`.
30
+ `bash ./build.sh` runs `./build.sh`. `bash -c STRING`, including a one-letter cluster such as
31
+ `bash -lc STRING`, runs an embedded string. `bash -s` and a bare `bash` read commands from
32
+ standard input, so the shell itself is the command.
33
+ - Any names you add with `allowWrappers`.
34
+
35
+ A command then reports in one of two ways:
36
+
37
+ - **`dynamicInvocation`**: the command that runs is not a static word. A word is static only when
38
+ its text is fully known from the source, with no parameter expansion, command substitution,
39
+ tilde, glob, or brace expansion. So `~/bin/tool` reports, because `~` depends on `$HOME`. An
40
+ option a wrapper doesn't recognize, such as `sudo -D /tmp rm x`, also reports: the rule never
41
+ guesses which word is the command.
42
+ - **`embeddedCommand`**: the command runs code embedded in a string, as with `bash -c …`,
43
+ `sh -c …`, or `env -S '…'`. It reports even when the string is a literal, because the string is
44
+ never checked as commands. Analyzing the string's contents is out of scope.
45
+
46
+ The rule is syntactic by design. It doesn't track values, so `cmd=ls; "$cmd"` reports. Aliases
47
+ need no handling: non-interactive shells don't expand them, so an alias name is an ordinary
48
+ static command word. Wrapper names match exactly, so `/bin/bash -c …` is a static command named
49
+ `/bin/bash`, not a shell wrapper.
50
+
51
+ It does not depend on `languageOptions`.
52
+
53
+ ## Options
54
+
55
+ - `allowInFunctions` (`string[]`, default `[]`): names of dispatcher functions. A
56
+ `dynamicInvocation` inside a function with one of these names, at any nesting depth, does not
57
+ report. The same command outside that function still reports. `embeddedCommand` still reports
58
+ inside these functions. Each use of the dispatcher stays visible at its call sites.
59
+ - `allowWrappers` (`string[]`, default `[]`): extra transparent wrappers, added to the built-in
60
+ list above. The word after one is checked as the command that runs. An added wrapper has no
61
+ known options, so a flag right after it (`with-retry -n 3 curl`) makes the command unknown and
62
+ reports. A name already in the built-in list keeps its built-in option handling.
63
+
64
+ ```js
65
+ // eslint.config.js
66
+ {
67
+ rules: {
68
+ 'sh/no-dynamic-invocation': [
69
+ 'error',
70
+ { allowInFunctions: ['dispatch'], allowWrappers: ['with-retry'] },
71
+ ],
72
+ },
73
+ }
74
+ ```
75
+
76
+ ## Examples
77
+
78
+ ### Incorrect
79
+
80
+ <!-- example: {"findings": 10, "messageIds": ["dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "embeddedCommand", "embeddedCommand"]} -->
81
+ ```sh
82
+ "$cmd" args # the command word is a parameter expansion
83
+ ${tool} x # same, braced
84
+ $(pick-tool) x # the command is computed by a command substitution
85
+ "${cmd[@]}" # array splat: the first element is the command
86
+ exec "$@" # exec runs its first argument
87
+ sudo -u deploy "$prog" # sudo runs a dynamic target
88
+ echo "$("$cmd")" # a dynamic command nested in a substitution
89
+ ~/bin/tool x # the path depends on $HOME
90
+ bash -c "$script" # embeddedCommand: a string of shell code
91
+ sh -c 'make test' # embeddedCommand, even for a literal string
92
+ ```
93
+
94
+ With `{ allowWrappers: ['with-retry'] }`:
95
+
96
+ <!-- example: {"options": {"allowWrappers": ["with-retry"]}, "findings": 1, "messageIds": ["dynamicInvocation"]} -->
97
+ ```sh
98
+ with-retry "$c" # the wrapped command is dynamic
99
+ ```
100
+
101
+ ### Correct
102
+
103
+ ```sh
104
+ ls -la "$dir" # dynamic arguments are fine; the command is `ls`
105
+ time ls # time is transparent; it runs `ls`
106
+ nohup env FOO=1 make # wrappers are followed to `make`
107
+ command -v "$tool" # looks a name up; runs nothing
108
+ bash ./build.sh # runs the literal script `./build.sh`
109
+ alias ll='ls -l'
110
+ ll # an alias name is a static command word
111
+ ```
112
+
113
+ With `{ allowInFunctions: ['dispatch'], allowWrappers: ['with-retry'] }`:
114
+
115
+ <!-- example: {"options": {"allowInFunctions": ["dispatch"], "allowWrappers": ["with-retry"]}} -->
116
+ ```sh
117
+ dispatch() {
118
+ "$@" # allowed inside the dispatcher
119
+ }
120
+ with-retry curl https://example.com # with-retry runs `curl`
121
+ ```
122
+
123
+ ## When not to use it
124
+
125
+ Turn the rule off for scripts nobody needs to vouch for, where running a computed command is the
126
+ point, such as a generic task runner. Prefer `allowInFunctions` for one dispatcher function over
127
+ disabling the rule for the whole file, and prefer a line-level
128
+ `# eslint-disable-next-line sh/no-dynamic-invocation` with a reason over a file-level disable,
129
+ so new dynamic commands still report.
130
+
131
+ ## Relationship to shellcheck
132
+
133
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
134
+ layer. Run shellcheck beside it.
135
+
136
+ shellcheck has no equivalent. It treats a dynamic command word as legitimate shell, and only
137
+ warns about how it is written, such as unquoted expansions (SC2086) or a string used where an
138
+ array belongs (SC2089/SC2090). It can't express the policy that every command must be
139
+ statically enumerable, which is what makes a script reviewable as a whole.
@@ -0,0 +1,106 @@
1
+ # `sh/no-eval`
2
+
3
+ Disallow `eval`, and `source` or `.` of a path that is not a static literal, so that every line
4
+ of code a script runs can be reviewed before it runs.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ------- | ------------ | --------- |
8
+ | `recommended`, `agent-authored` | not fixable | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ A script you can review is a script whose code is all in front of you. Two constructs defeat
13
+ that by building the code to run at run time:
14
+
15
+ - **`eval`** re-parses its arguments as shell code when the line executes. The code a reviewer
16
+ reads is a string, not commands. That holds even for `eval "literal"`: the literal is a second
17
+ layer of shell parsing that no static tool, this linter included, treats as commands. So the
18
+ rule reports **every** `eval`, whatever its arguments.
19
+ - **`source <path>` and `. <path>`** run another file in the current shell. When the path is
20
+ only known at run time, a reviewer cannot tell which file, and so which code, runs.
21
+
22
+ The rule inspects every `CallExpr` (simple command), wherever it appears: top level, inside
23
+ functions, loops, conditionals, and command substitutions.
24
+
25
+ - **`eval`** (message `noEval`) is reported on the whole command. It is recognized as the
26
+ command that actually runs after transparent wrappers are followed, so `command eval …`,
27
+ `builtin eval …`, and `exec eval …` all report. `command -v eval` does not: it only looks
28
+ `eval` up.
29
+ - **`source` / `.`** (message `dynamicSource`) is reported on its path word when that word is not
30
+ static. A word is static only when its text is fully known from the source, with no parameter
31
+ expansion, command substitution, tilde, glob, or brace expansion. So `source ~/lib.sh` reports,
32
+ because `~` depends on `$HOME`. Words after the path are positional parameters for the sourced
33
+ file and are not checked. Like `eval`, `source` and `.` are recognized as the command that
34
+ actually runs after transparent wrappers are followed, so `command source "$p"` and
35
+ `builtin . "$p"` report exactly as `source "$p"` does, and `command source ./static.sh` is
36
+ clean. The path is the word right after the effective command.
37
+
38
+ The rule deliberately ignores:
39
+
40
+ - the word `eval` anywhere other than the command position (`echo eval`), and in comments;
41
+ - a command word that is itself dynamic (`"$cmd" args`), and command strings passed to another
42
+ shell (`bash -c "$x"`, `sh -c …`).
43
+
44
+ It does not depend on `languageOptions`.
45
+
46
+ ## Options
47
+
48
+ This rule has no options.
49
+
50
+ ```js
51
+ // eslint.config.js
52
+ {
53
+ rules: {
54
+ 'sh/no-eval': 'error',
55
+ },
56
+ }
57
+ ```
58
+
59
+ ## Examples
60
+
61
+ ### Incorrect
62
+
63
+ <!-- example: {"findings": 10, "messageIds": ["noEval", "noEval", "noEval", "noEval", "noEval", "dynamicSource", "dynamicSource", "dynamicSource", "dynamicSource", "dynamicSource"]} -->
64
+ ```sh
65
+ eval "echo hi" # eval, even of a literal string
66
+ eval "$cmd" # eval of a value assembled at run time
67
+ command eval "$cmd" # eval reached through the `command` wrapper
68
+ builtin eval "$cmd" # eval reached through the `builtin` wrapper
69
+ exec eval "$cmd" # eval reached through the `exec` wrapper
70
+ source "$DIR/lib.sh" # the path depends on $DIR
71
+ . $(pick) # the path is computed by a command substitution
72
+ source ~/lib.sh # the path depends on $HOME
73
+ command source "$p" # source reached through the `command` wrapper
74
+ builtin . "$p" # `.` reached through the `builtin` wrapper
75
+ ```
76
+
77
+ ### Correct
78
+
79
+ ```sh
80
+ source ./lib.sh # a literal relative path
81
+ . "/etc/profile" # a quoted literal path
82
+ source ./lib.sh "$1" # only the path word must be static
83
+ command source ./lib.sh # a literal path stays clean behind a wrapper
84
+ echo eval # `eval` is an argument here, not a command
85
+ command -v eval # looks `eval` up; never runs it
86
+ ```
87
+
88
+ ## When not to use it
89
+
90
+ Turn the rule off for a script whose purpose is to run generated code, for example a bootstrap
91
+ step that `eval`s the output of a tool like `ssh-agent -s` or `brew shellenv`, where the tool,
92
+ not the script, is the reviewed artifact. Prefer disabling it on that line
93
+ (`# eslint-disable-next-line sh/no-eval`) over disabling it for the whole file, so new uses
94
+ still report.
95
+
96
+ ## Relationship to shellcheck
97
+
98
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
99
+ layer. Run shellcheck beside it.
100
+
101
+ This rule is one of eslint-sh's two deliberate overlaps with shellcheck. shellcheck flags
102
+ `eval` only in specific misuse patterns, such as SC2294 (`eval` on an array negates the benefit
103
+ of arrays), and flags `source` of a non-constant path as SC1090 because shellcheck cannot follow
104
+ it. Both treat these as hazards to work around. This rule treats them as a policy: code that
105
+ can't be statically reviewed doesn't ship, so it reports every `eval`, including the uses
106
+ shellcheck accepts. If you run shellcheck and only want its narrower checks, disable this rule.
@@ -0,0 +1,239 @@
1
+ # `sh/no-masked-exit-status`
2
+
3
+ Disallow `local`, `declare`, `typeset`, `export` and `readonly` with a value taken from a command substitution, because the builtin's exit status replaces the command's.
4
+
5
+ | Configs | Fixable | Languages |
6
+ | ---------------- | ----------------------------------------- | --------- |
7
+ | `agent-authored` | suggestions (split into two statements) | `sh/sh` |
8
+
9
+ ## Rule details
10
+
11
+ `local x=$(cmd)` runs `cmd`, then runs `local`, and the statement's exit status is the status of
12
+ `local`, which succeeds. A failing `cmd` therefore does not stop a script under `set -e`, and in
13
+ `local x=$(cmd) && next` the `next` runs anyway. `export X=$(cmd)`, `readonly X=$(cmd)`,
14
+ `declare x=$(cmd)` and `typeset x=$(cmd)` behave the same way. This is the most common way a
15
+ script that sets `set -euo pipefail` still swallows a failure, which is why
16
+ [`require-strict-mode`](./require-strict-mode.md) alone does not close the hole.
17
+
18
+ The rule reports a `local`, `declare`, `typeset`, `export` or `readonly` operand of the form
19
+ `name=value` whose value contains a command substitution, `$(…)` or backticks. The substitution
20
+ can be the whole value, part of a larger word (`export DIR=/opt/$(basename "$p")`), inside double
21
+ quotes, or inside a parameter expansion (`"${y:-$(z)}"`, `${y:$(n):1}`, `${a[$(i)]}`) or an
22
+ arithmetic expansion (`$(( $(count) + 1 ))`). In a plain assignment a failing substitution in any of
23
+ those places fails the assignment, so `set -e` stops the script in Bash, dash, zsh and ksh93; behind a
24
+ declaration builtin none of them does. The rule reads the builtins behind
25
+ `builtin` and `command` too. It reports once per operand, on the operand, and the message names the
26
+ builtin and the variable.
27
+
28
+ What the rule does not report:
29
+
30
+ - A value with no command substitution: `local x=$y`, `local x="${y:-z}"`, `local n=$((a + b))`.
31
+ - A declaration with no value: `local x`, `export X`, `readonly X`.
32
+ - A plain assignment: `x=$(cmd)`. Its exit status is the command's.
33
+ - The split forms: `local x; x=$(cmd)` and `X=$(cmd); export X`. `export X; X=$(cmd)` is not reported
34
+ either, but it exports the old value to the command, which the original did not.
35
+ - A process substitution used as a value (`local p=<(cmd)`). It expands to a path, so there is no
36
+ status to lose.
37
+ - An array value (`local -a a=($(cmd))`). shellcheck does not report SC2155 for it either.
38
+
39
+ **On purpose, and noisy for some code.** `export PATH="$(dirname "$0"):$PATH"` is reported. The
40
+ substitution's failure is hidden there like anywhere else, and the split
41
+ (`PATH="$(dirname "$0"):$PATH"` then `export PATH`) is safe because `export` creates no scope. To quiet
42
+ it, split the line as suggested, add an ESLint disable comment for the line, or set
43
+ `builtins: ["local"]` to stop checking `export`, `readonly`, `declare` and `typeset`.
44
+
45
+ The corpus the rule was measured on shows how much of the output is worth acting on. Of 40 evenly
46
+ spaced reports in eight open-source projects, 25 were real masked failures (`mktemp`, `get_*`
47
+ functions, `cd … && pwd`, `docker ls`) and 15 were low value: a `date`, a `$(cat)`, a command ending
48
+ in `|| true`, or a pipeline whose last stage already decides the status (`… | awk`, `| wc -l`). So
49
+ about one report in three is a correct finding that you may not want to fix. That is
50
+ the same noise as shellcheck's SC2155, which the rule matches on the shared constructs. In
51
+ `agent-authored` the rule is an `error`, because the preset's contract is that a failure stops
52
+ the script. A team that wants a gentler start can set it to `warn`, or use `onlyWithErrexit`, which
53
+ drops the findings in files and functions where errexit is not on.
54
+
55
+ **Suggestion.** There is no autofix. Splitting the declaration changes behavior on purpose: under
56
+ `set -e` a failure of `cmd` now stops the script. The editor suggestion declares the names first
57
+ and assigns each substituted one in its own statement. It keeps the builtin's flags, the
58
+ indentation and a trailing comment.
59
+
60
+ <!-- transform: {"kind": "suggestion"} -->
61
+
62
+ ```sh
63
+ f() {
64
+ local -i n=$(count) # lines
65
+ }
66
+ # becomes
67
+ f() {
68
+ local -i n
69
+ n=$(count) # lines
70
+ }
71
+ ```
72
+
73
+ With several operands, every name is declared first and then each substituted one is assigned:
74
+ `local APP="" REMOTE="$(fn-get-remote)" HOST=""` becomes `local APP="" REMOTE HOST=""` followed by
75
+ `REMOTE="$(fn-get-remote)"`. `export` goes the other way, because exporting first would give the
76
+ command the old value in its environment: `export X=$(cmd)` becomes `X=$(cmd)`, `export X`. When the
77
+ declaration forbids a later assignment (`readonly`, or `-r`),
78
+ the suggestion assigns first and makes the variable read-only afterwards:
79
+ `declare -r x=$(cmd)` becomes `declare x`, `x=$(cmd)`, `readonly x`.
80
+
81
+ The suggestion is withheld where the split would not mean the same thing, and the report stays:
82
+
83
+ - The value reads a name that the split moves before it. For `local`, `declare` and `typeset`
84
+ inside a function that includes the variable itself: `local P=$(echo "$P" | cut -d. -f1)`
85
+ would read the new, empty local after the split. `export PATH="$(…):$PATH"` is safe to split,
86
+ because `export` creates no new scope. For every builtin it also includes the other operands of
87
+ the same declaration. Any mention of such a name in the value counts, because a command can read a
88
+ variable by name: `local x=$([ -v x ] && echo set)`, `local x=$(declare -p x)` and
89
+ `export A=1 B=$(printenv A)` are withheld. So can an unrelated word, as in
90
+ `local name=$(git config user.name)`. Under `-i` the builtin evaluates each value as arithmetic,
91
+ so this reaches the values without a substitution too: `local -i a=$(cmd) b=a+1` is withheld. So
92
+ does `export`, which runs after the assignments: `export A=$(cmd) B=$A` is withheld. **Limit:** a
93
+ command that dumps every variable names none of them, so `local n=$(declare -p | wc -l)` and
94
+ `local n=$(set | wc -l)` keep their suggestion and see one more variable after the split.
95
+ - A scoped declaration of a variable the shell or its commands consult, such as `PATH`, `IFS`, `HOME`,
96
+ `LANG`, `TERM`, `PWD` or `TMPDIR`. `local PATH; PATH=$(cmd)` empties `PATH` first, so `cmd` is not found.
97
+ **Limit:** any other exported variable can be read by a command too, and that is not visible in the
98
+ script: after `export X=1`, a command in `local X=$(cmd)` that reads `X` sees a different environment
99
+ once the line is split. The same goes for `export A=$(c1) B=$(./tool)` when `A` is already exported:
100
+ `./tool` sees the new `A` after the split, and the old one before it.
101
+ - The value runs code the rule cannot read, and that code could read a name the split declares
102
+ earlier. That means the substitution runs `eval`, `source` or `.`, a command whose name is not
103
+ static (`$($cmd)`), or a function defined in the same file:
104
+ `g() { echo "$x"; }; f() { local x=$(g); }` is withheld, as is `local y=1 x=$(g)` when `g` reads
105
+ `y`. This applies where the split changes what is visible: `local`, `declare` or `typeset` inside a
106
+ function, a declaration with several operands, or one with any flag. A lone `export x=$(g)` keeps
107
+ its suggestion. **Limit:** a function defined in another file, such as one from a script that
108
+ is sourced, cannot be seen, so the rule cannot tell that a call to it reads the variable. The
109
+ suggestion may then change what that function sees. Read a suggestion before applying it.
110
+ - The statement has prefix assignments, as in `A=1 export x=$(cmd)`. The replacement covers the
111
+ whole command and would delete them.
112
+ - A flag changes what the split means or is not valid with an assignment. `declare -g x=$(cmd)`
113
+ writes the global, while the split writes the nearest local, so any `-g` is withheld (`-gx` too).
114
+ `local -x x=$(cmd)` would export the name to the command before it runs, so `-x` is withheld. After
115
+ `declare -n r`, the assignment in the split writes through `r` where the original rebinds it, so
116
+ `-n` is withheld on `local`, `declare` and `typeset`.
117
+ `-f`, `-F`, `-p` and flags the builtin does not have (`export -x`) are withheld as well, so the
118
+ suggestion never turns an error into an assignment. `local` outside a function is an error and
119
+ is also withheld. The flags kept are `-a -A -i -l -r -t -u` (`-n` for `export`).
120
+ - The declaration is not a standalone statement: part of an `&&`/`||` list or a pipeline (including
121
+ `[[ … ]] && local x=$(cmd)`), an `if` or `while` condition, negated, in the background, or
122
+ redirected.
123
+ - The builtin is reached through `builtin` or `command`.
124
+ - An operand is an append (`x+=$(cmd)`) or has a subscript (`a[1]=$(cmd)`), or `readonly` has flags.
125
+
126
+ Under `dialect: "posix"` the parser has no declaration clause and reads `local x=$(cmd)` as an
127
+ ordinary command; the rule reads it the same way and offers the same suggestion. Otherwise the rule
128
+ does not depend on `languageOptions`.
129
+
130
+ ## Options
131
+
132
+ | Option | Type | Default | Effect |
133
+ | ----------------- | ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `onlyWithErrexit` | `boolean` | `false` | Report only where `errexit` is known to be on (below). |
135
+ | `allowShebangOnly` | `boolean` | `false` | With `onlyWithErrexit`, count options on the shebang line (`#!/bin/bash -e`, `#!/bin/sh -e`, `#!/usr/bin/env -S bash -e`) as enabled. Same meaning and default as the option of that name in [`require-strict-mode`](./require-strict-mode.md), and read by the same code. |
136
+ | `builtins` | `string[]` | `["local", "declare", "typeset", "export", "readonly"]` | Which declaration builtins to check, for example `["local"]` for teams that accept `export X=$(…)` at the top level. |
137
+
138
+ **Where `errexit` is on.** The rule reads the plain top-level `set` statements (`set -e`,
139
+ `set -euo pipefail`, `set -o errexit`, `set +e`) in order, with the same reading
140
+ `require-strict-mode` uses for each one. A statement is checked when they leave `errexit` on
141
+ before it. So `export x=$(cmd)` followed by `set -e` is not checked, and a `set -e` after five
142
+ statements counts for the code after it. Unlike `require-strict-mode` there is no five-statement
143
+ window, because the order is tracked. A function body is read at the point where it is defined, so
144
+ `set -e` before `f() { … }` covers `f`.
145
+
146
+ `allowShebangOnly` is off by default for the reason it is off in `require-strict-mode`: options on the
147
+ shebang line apply only when the script is run directly, and `bash script.sh` ignores them. Turn it
148
+ on if your scripts are always executed directly.
149
+
150
+ Under `dialect: "bats"` a statement written directly in an `@test` body, or directly in a top-level
151
+ `setup`, `teardown`, `setup_file` or `teardown_file` function, counts as on, because bats runs
152
+ those bodies with `errexit` on, until the body runs `set +e`. This is the reading `no-unchecked-cd`
153
+ uses. A declaration nested deeper in an `@test` (inside an `if` or a loop) falls back to the
154
+ file-level reading, so it is not checked.
155
+
156
+ What is not tracked: a `set` inside a function, a `{ … }` group, a condition or a loop; `set -e` in a
157
+ file that is sourced; `errexit` given on the command line (`bash -e script.sh`, `sh -e`); and a
158
+ `set -e` or `set +e` between the definition of a function and a call to it. So
159
+ `f() { local x=$(cmd); }; set -e; f` is not checked. A function called on the left of `||` runs with
160
+ `errexit` off, which is not followed either, so `set -e; f() { local x=$(cmd); echo ok; }; f || g` is
161
+ reported.
162
+
163
+ `onlyWithErrexit` does not apply where the script reads the status explicitly, because there the
164
+ status is always the builtin's. Those positions are the left side of `&&` or `||`, the right side of
165
+ one that is itself read (so `true && export x=$(false) && echo c` reports the `export`), and the last
166
+ command of an `if`, `elif`, `while` or `until` condition, including the middle of an `&&` chain
167
+ inside that condition (`if a && export x=$(false); then`). They are reported in every file. Status
168
+ read further away is not followed: the caller of a function (`f || echo failed`), the last command
169
+ of an `if` or `case` body, or a later `$?`.
170
+
171
+ ```js
172
+ // eslint.config.js
173
+ {
174
+ rules: {
175
+ 'sh/no-masked-exit-status': ['error', { onlyWithErrexit: true, builtins: ['local'] }],
176
+ },
177
+ }
178
+ ```
179
+
180
+ ## Examples
181
+
182
+ ### Incorrect
183
+
184
+ <!-- example: {"findings": 6, "messageIds": ["maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus"]} -->
185
+
186
+ ```sh
187
+ f() {
188
+ local image=$(get_image "$1") # reported: local's status replaces get_image's
189
+ local dir="$(dirname "$1")" ver=1 # reported: the quoted form too
190
+ declare -r token=$(read_token) # reported: -r does not help
191
+ }
192
+ export LE_DIR="$(echo "$2" | sed 's|/$||')" # reported: export
193
+ readonly N_CACHE="$(mktemp -d)" # reported: readonly
194
+ [ -n "$x" ] && export Y=`date` # reported: backticks, after &&
195
+ ```
196
+
197
+ ### Correct
198
+
199
+ ```sh
200
+ f() {
201
+ local image dir
202
+ image=$(get_image "$1") # the assignment keeps get_image's status
203
+ dir=$(dirname "$1")
204
+ local n=$((1 + 2)) y=$z # no command substitution
205
+ local -a files=($(ls)) # array values are not reported
206
+ local p=<(sort file) # a process substitution is a path
207
+ }
208
+ LE_DIR=$(pwd); export LE_DIR
209
+ readonly VERSION=1.2
210
+ ```
211
+
212
+ ## When not to use it
213
+
214
+ Turn the rule off, or set `onlyWithErrexit`, for scripts whose failures are never meant to stop
215
+ them and where the declaration idiom is accepted house style. `builtins: ["local"]` suits teams that
216
+ accept `export X=$(…)` at the top level and only want function variables split.
217
+
218
+ ## Relationship to shellcheck
219
+
220
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
221
+ layer. Run shellcheck beside it.
222
+
223
+ shellcheck reports this construct as SC2155 ("Declare and assign separately to avoid masking return
224
+ values") and offers no fix for it. The rule exists anyway for three reasons. `require-strict-mode`
225
+ makes `set -euo pipefail` a contract, and this construct is the most common way a script that
226
+ honours the contract still swallows a failure, so `agent-authored`, which requires strict mode,
227
+ closes the hole. eslint-sh can offer the split as an editor suggestion, which shellcheck cannot. And
228
+ `onlyWithErrexit` and `builtins` limit the policy to the files and builtins where the status matters.
229
+
230
+ It is not enabled in `recommended`, which is meant to run beside shellcheck, because the two would
231
+ report the same line twice. Where both run, disable SC2155 or this rule. The two agree on scalar
232
+ values for all five builtins. The rule reports more than shellcheck 0.11.0 in three places: a
233
+ substitution inside a parameter expansion operand, a substring offset, an array subscript or an
234
+ arithmetic expansion (`local x="${y:-$(cmd)}"`, `local n=$(( $(cmd) + 1 ))`), which shellcheck does
235
+ not look into although real shells mask their status the same way, and `local -r x=$(cmd)` or
236
+ `declare -r x=$(cmd)`, which shellcheck exempts. The rule's
237
+ suggestion handles the second by assigning first and then running `readonly`. It also reads the
238
+ `builtin` and `command` forms, and honours neither a `# shellcheck disable=SC2155` directive nor
239
+ `.shellcheckrc`; use an ESLint disable comment instead.