@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,216 @@
1
+ # `sh/no-unused-declarations`
2
+
3
+ Disallow declaring external commands a script never uses, so that a script's declared
4
+ dependencies match what it actually runs.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ------- | ----------------------------- | --------- |
8
+ | `agent-authored` (inert until a declaration convention is bound) | autofix (via the convention) | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ A declaration that outlives the command it names makes a script's stated dependencies lie. An
13
+ installer provisions a tool nothing runs, and a reviewer trusts a list that is no longer the
14
+ script's real surface. This rule reports each declared command the script never uses. It is the
15
+ converse of [`sh/no-undeclared-commands`](./no-undeclared-commands.md).
16
+
17
+ The rule needs a **declaration convention**, which says how your project declares commands. Bind
18
+ one with `settings.sh.declarationConvention`. The plugin ships one, `commentPragma`, which reads
19
+ `# uses: jq curl` comments from a script's header. See
20
+ [Declaration conventions](../declaration-conventions.md) to write your own. **With no convention
21
+ bound, the rule reports nothing**, so turning it on is safe until your project picks a convention.
22
+
23
+ The fix deletes declarations, so the rule only reports a name it is sure the script does not
24
+ run. That makes it more cautious than `sh/no-undeclared-commands`, which reports in the other
25
+ direction.
26
+
27
+ **Incomplete usage: the rule reports nothing in a file that may run a command it cannot name.**
28
+ If any command in the file is only known at run time, that command could be any declared name,
29
+ so no declaration in the file is reported. These are the commands
30
+ [`sh/no-dynamic-invocation`](./no-dynamic-invocation.md) reports. Enable it to find them and
31
+ replace them with literal commands:
32
+
33
+ - a command word that is an expansion: `"$cmd" x`, `"$@"`, a loop variable
34
+ (`for t in jq curl; do "$t" --version; done`), `"${tools[@]}"`, or `$(pick-tool) x`. This
35
+ also applies behind a wrapper (`sudo "$prog"`) or behind a wrapper flag the rule doesn't
36
+ recognize;
37
+ - a command string run by a shell: `bash -c '…'`, `sh -c '…'`, or `env -S '…'`, also when the
38
+ shell or `env` is run by path (`/bin/bash -c '…'`, `/usr/bin/env -S '…'`);
39
+ - a shell given a command string as the argument of another command: a word after the command
40
+ name that is a shell (`bash`, `sh`, `dash`, `ksh`, `mksh` or `zsh`, by name or by path),
41
+ followed later by an option cluster containing `c` (`-c`, `-ec`, `-xc`). This covers
42
+ `xargs sh -c '…'`, `find . -exec sh -c '…' \;`, `setsid -f bash -c '…'` and
43
+ `busybox sh -c '…'`. A shell name with no `-c` after it, as in `xargs echo sh`, is fine;
44
+ - `eval`;
45
+ - sourcing another file: `. ./lib.sh` or `source ./lib.sh`;
46
+ - a shell (`bash`, `sh`, `dash`, `ksh`, `mksh` or `zsh`, by name or by path) run with no script
47
+ operand and no `-c`, which reads its code from standard input: `curl -fsSL x | bash`,
48
+ `bash < script`, `bash -s`. A shell given a script, as in `bash script.sh`, is fine;
49
+ - a `trap` whose action is a code string: `trap 'rm -f "$tmp"' EXIT`. A trap whose action is a
50
+ plain name, such as `trap cleanup EXIT`, is fine. So are `trap '' INT` (ignore) and
51
+ `trap - EXIT` (reset).
52
+
53
+ **Used.** Otherwise, a declared name is used when any of the following holds:
54
+
55
+ - **The script runs it as a command.** The rule counts commands exactly as
56
+ `sh/no-undeclared-commands` does, so a command that rule counts as used is never reported
57
+ here. That covers commands anywhere in the file: the top level, loops, conditionals,
58
+ pipelines, and command substitutions (`$(…)` and backquotes) in any position. It also covers
59
+ function bodies, including a function that is never called. That is deliberately cautious: the
60
+ rule doesn't decide which functions run.
61
+ - **The script runs it through a wrapper.** `sudo rsync -a . host:` uses both `sudo` and
62
+ `rsync`, and in `bats` files `run curl x` uses `curl`. Your own wrappers count once you list
63
+ them in `transparentWrappers` (below).
64
+ - **It is a whole argument word of any command.** Many commands run their arguments, so the
65
+ name counts as used when an argument's full static text is that name: `xargs jq .`,
66
+ `find . -exec jq . {} \;`, `watch jq . f`, `command -v jq`. Part of a word doesn't count:
67
+ `echo jqfilter` doesn't use `jq`.
68
+ - **A path to it is used.** A command or argument word written as a path also uses its basename:
69
+ `/usr/bin/jq` and `./bin/jq` use `jq`. A declared path, such as `./bin/jq`, is used only by
70
+ that exact path.
71
+
72
+ Not counted:
73
+
74
+ - **Builtins and functions defined in the file are not used commands.** Declaring one is
75
+ reported unless the name also appears as an argument word. `# uses: cd` is stale even in a
76
+ script that runs `cd`. Which names are builtins depends on `languageOptions.dialect`; see
77
+ [`sh/no-undeclared-commands`](./no-undeclared-commands.md#rule-details) for the lists.
78
+ - **Commands run on another host are not counted.** In `ssh host 'jq . f'`, the remote host
79
+ runs `jq` and this script doesn't, so `jq` is not used. Written as separate words,
80
+ `ssh host jq . f` makes `jq` an argument word, and that does count.
81
+
82
+ Each declaration of an unused command is reported once. A name declared twice and never used is
83
+ reported at both declarations. Where the report goes depends on where the convention says the
84
+ declaration lives (see [where a declaration lives](../declaration-conventions.md#the-interface)):
85
+
86
+ - **In the script** (message `unusedDeclaration`): at the text that declares it. With
87
+ `commentPragma`, that is the name in the `# uses:` line.
88
+ - **In another file** (message `unusedFileDeclaration`) or **from a source described in words**, such as a
89
+ list in the config or a registry snapshot (message `unusedDescribedDeclaration`): the message names
90
+ the source, as `path:line` (or just `path`) or as the source's description, for example
91
+ `'git' is declared in scripts/deploy.commands:3 but this script never uses it. Remove it there
92
+ so the script's stated dependencies match what it runs.` ESLint can only report inside the file
93
+ it lints, so these reports sit at line 1, column 1, with no end: a position for the file as a
94
+ whole, not for any text in it. The message says where to look.
95
+
96
+ **No comment silences a report about a declaration outside the script when the script starts
97
+ with a shebang.** The shebang must be the whole first line, so `#!/bin/sh # eslint-disable-line …`
98
+ is not a directive, and a directive on line 2 (`# eslint-disable-next-line …` or
99
+ `# eslint-disable …`) covers only later lines. (Without a shebang, a `# eslint-disable …` comment
100
+ on line 1 works.) To silence one, do one of these:
101
+
102
+ - fix the declaration in the file or source the message names;
103
+ - turn the rule off for those scripts with a `files` override:
104
+ `{ files: ['scripts/legacy/*.sh'], rules: { 'sh/no-unused-declarations': 'off' } }`;
105
+ - record the current reports with ESLint's bulk suppressions,
106
+ `eslint --suppress-rule sh/no-unused-declarations`. Later runs hide them while a file has no
107
+ more reports of the rule than were recorded.
108
+
109
+ See [silencing a report about a declaration outside the
110
+ script](../declaration-conventions.md#silencing-a-report-about-a-declaration-outside-the-script).
111
+
112
+ ### Autofix
113
+
114
+ Only the convention knows its own syntax, so the convention makes the fix. When the bound
115
+ convention provides a `removeDeclaration` hook, each report about a declaration in the script
116
+ carries the edit that hook returns. When it has no hook, or the hook returns `null` for a
117
+ declaration, that report has no fix. A declaration in another file or a described source never has a
118
+ fix: an ESLint fix can only edit the script being linted.
119
+
120
+ With `commentPragma`, the fix removes the name and the space before or after it, so
121
+ `# uses: jq curl` becomes `# uses: jq` when only `jq` is used:
122
+
123
+ <!-- transform: {"kind": "fix", "convention": "commentPragma"} -->
124
+
125
+ ```sh
126
+ # uses: jq curl
127
+ jq .
128
+ # becomes
129
+ # uses: jq
130
+ jq .
131
+ ```
132
+
133
+ When the last name on a line goes,
134
+ the whole line goes with its `\n` or `\r\n`. When several names on one line are unused, their
135
+ edits touch, so ESLint applies one per pass. `eslint --fix` runs passes until nothing changes, so
136
+ a line whose every name is unused is removed entirely.
137
+
138
+ ## Options
139
+
140
+ - `transparentWrappers` (`string[]`, default `[]`): names of your project's own wrapper
141
+ commands, added to the built-in wrappers. The word after a listed wrapper is the command it
142
+ runs. With `['with-retry']`, `with-retry curl x` runs `curl`, and `with-retry "$tool"` runs a
143
+ command the rule can't name, so the file is incomplete usage and reports nothing. A listed
144
+ wrapper takes no flags, so `with-retry -n 3 curl` is incomplete usage too: the rule stops at
145
+ `-n` and can't tell what runs. Give this option the same value as
146
+ `sh/no-undeclared-commands`' option of the same name, or the two rules will disagree about what
147
+ the script uses. Your network wrapper doesn't need listing here when it is set in
148
+ `settings.sh.networkWrapper`: this rule looks through it, with the options its spec declares
149
+ (see [`sh/require-network-wrapper`](./require-network-wrapper.md#declaring-the-wrapper-once-settingsshnetworkwrapper)).
150
+
151
+ The convention is not a rule option. Bind it in `settings`, because ESLint copies rule options
152
+ with `structuredClone`, which cannot copy a convention's methods.
153
+
154
+ ```js
155
+ // eslint.config.js
156
+ import sh, { commentPragma } from '@eslint-sh/eslint-plugin';
157
+
158
+ const wrappers = { transparentWrappers: ['with-retry'] };
159
+
160
+ export default [
161
+ {
162
+ files: ['**/*.sh'],
163
+ plugins: { sh },
164
+ language: 'sh/sh',
165
+ settings: { sh: { declarationConvention: commentPragma } },
166
+ rules: {
167
+ 'sh/no-undeclared-commands': ['error', wrappers],
168
+ 'sh/no-unused-declarations': ['error', wrappers],
169
+ },
170
+ },
171
+ ];
172
+ ```
173
+
174
+ ## Examples
175
+
176
+ With `commentPragma` bound and `{ transparentWrappers: ['with-retry'] }`:
177
+
178
+ ### Incorrect
179
+
180
+ <!-- example: {"convention": "commentPragma", "options": {"transparentWrappers": ["with-retry"]}, "findings": 3} -->
181
+ ```sh
182
+ #!/usr/bin/env bash
183
+ # uses: jq curl docker # docker is never run
184
+ # uses: cd # cd is a builtin, never a declared dependency
185
+ # uses: lint # lint is a function defined below
186
+ lint() { shellcheck "$@"; }
187
+ curl -s "$URL" | jq .
188
+ ```
189
+
190
+ ### Correct
191
+
192
+ <!-- example: {"convention": "commentPragma", "options": {"transparentWrappers": ["with-retry"]}} -->
193
+ ```sh
194
+ #!/usr/bin/env bash
195
+ # uses: jq curl git sudo date with-retry
196
+ with-retry curl -s "$URL" | jq . # curl runs through the configured wrapper
197
+ sudo git pull # git runs through sudo
198
+ echo "built $(date +%s)" # date is used inside $(…)
199
+ ```
200
+
201
+ ## When not to use it
202
+
203
+ Turn the rule off when your project doesn't track script dependencies, or when its declarations
204
+ deliberately list more than the script runs, such as tools a child script needs. The rule only
205
+ reads the file it lints, so a command that only `bash child.sh` or `./child.sh` runs reads as
206
+ unused here.
207
+
208
+ ## Relationship to shellcheck
209
+
210
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
211
+ layer. Run shellcheck beside it.
212
+
213
+ shellcheck has no equivalent. It checks how a script uses commands, not which commands the
214
+ script says it depends on, and it has no notion of a project's declaration convention to compare
215
+ against. Keeping a dependency manifest honest is project policy, which is the kind of rule
216
+ shellcheck cannot be configured to express.
@@ -0,0 +1,221 @@
1
+ # `sh/prefer-local`
2
+
3
+ Require variables assigned inside a function to be declared `local`, so a function does not
4
+ overwrite globals its callers rely on.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ---------------- | ------- | --------- |
8
+ | `agent-authored` | autofix | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ Shell variables are global by default. A helper that assigns `i`, `tmp` or `result` silently
13
+ overwrites the same-named variable of its caller, and of the caller's caller, because shell scoping
14
+ is dynamic. This rule reports an assignment inside a function body to a name that the function did
15
+ not declare `local`, `declare` or `typeset`.
16
+
17
+ What the rule treats as an assignment:
18
+
19
+ - A plain assignment statement: `x=1`, `x+=1`, `x=$(cmd)`. An assignment that prefixes a command
20
+ (`x=1 cmd`) only changes that command's environment and is not reported.
21
+ - The variables `read` assigns: `read x`, `read -r a b`, `read -a arr`. The rule skips `read`'s
22
+ options and their arguments (`-p "prompt: "`, `-d ''`, `-n 3`, …). A target that is not static
23
+ (`read "$name"`) is not guessed at.
24
+ - The loop variable of `for x in …` and `select x in …`.
25
+
26
+ What the rule does not report:
27
+
28
+ - A name declared `local`, `declare` or `typeset` **earlier** in the function. A declaration after
29
+ the assignment does not cover it.
30
+ - A name assigned at top level **before** the function is defined. The rule treats it as an
31
+ intentional global that the function is meant to share.
32
+ - A name declared global on purpose: `export x=…`, `readonly x=…`, `declare -g x=…`,
33
+ `typeset -g x=…`.
34
+ - A name listed in `allow`, or matched by `allowPattern`. By default `allowPattern` is
35
+ `^[A-Z][A-Z0-9_]*$`, so UPPER_CASE names, which scripts conventionally use for globals, are
36
+ allowed.
37
+ - Anything in a subshell `( … )`, a command substitution `$( … )` or a process substitution, and
38
+ functions whose body is a subshell (`f() ( … )`): those run in a child process, so nothing leaks.
39
+ - Anything in a pipeline element, such as `echo hi | read x` or `ls | while read -r line; do …; done`.
40
+ Pipeline elements run in subshells, so the assignment is lost rather than leaked. The exception is
41
+ a file that enables `shopt -s lastpipe` (anywhere in the file): the **last** element of each
42
+ pipeline then runs in the current shell, and the rule checks it like any other statement. Earlier
43
+ elements are still skipped.
44
+
45
+ Each function is its own scope. A function defined inside another function is checked on its own.
46
+ Only the **first** assignment to a name in a function is reported, since one declaration covers the
47
+ rest.
48
+
49
+ `local` is not part of POSIX, so under `languageOptions: { dialect: 'posix' }` the rule reports
50
+ nothing.
51
+
52
+ **Autofix.** The fix inserts one `local <name> <name> …` statement as the first statement of the
53
+ function body, naming every name the function leaks, and leaves the assignments untouched:
54
+
55
+ ```sh
56
+ # before
57
+ f() {
58
+ out=$(compute)
59
+ }
60
+
61
+ # after
62
+ f() {
63
+ local out
64
+ out=$(compute)
65
+ }
66
+ ```
67
+
68
+ The fix does not rewrite `out=$(compute)` into `local out=$(compute)` on purpose. `local` is a
69
+ command, so its exit status is its own, not that of `compute`: `local out=$(false)` succeeds, and
70
+ `set -e` or `|| handle_error` never sees the failure (shellcheck SC2155). A separate declaration
71
+ keeps the assignment's exit status intact.
72
+
73
+ The fix declares a variable that was global. If the function _reads_ the global before assigning it
74
+ (`count=$((count + 1))`), declaring it `local` makes that read see an empty value instead, so
75
+ review the function before accepting the fix. Every report in a function carries the same fix, a
76
+ single `local` declaring all of the function's reported names, so `eslint --fix` settles the
77
+ function in one pass however many names it leaks (`a` then `b` becomes `local a b`). Fixes that
78
+ insert at the same point conflict, and ESLint applies one per pass and stops after 10, which is why
79
+ one fix per name would leave a function with more than 10 names unfixed. The declaration therefore
80
+ also covers a name whose report a user suppressed with a directive, so review the function if you
81
+ suppress a name in it. Each report, message and suggestion is still per name. A function whose body
82
+ is not a `{ … }` block (`f() if …; fi`) is reported without a fix.
83
+
84
+ ## Options
85
+
86
+ This rule takes one options object:
87
+
88
+ - `allow` (`string[]`, default `[]`): variable names that may be assigned in a function without
89
+ `local`.
90
+ - `allowPattern` (`string`, default `"^[A-Z][A-Z0-9_]*$"`): a regular expression, compiled with the
91
+ `u` flag, that exempts every name it matches. A value that is not a valid regular expression is a
92
+ configuration error. Setting it replaces the default, so `allowPattern: '^g_'` stops exempting
93
+ UPPER_CASE names.
94
+
95
+ ```js
96
+ // eslint.config.js
97
+ {
98
+ rules: {
99
+ 'sh/prefer-local': ['error', {
100
+ allow: ['result'],
101
+ allowPattern: '^[A-Z][A-Z0-9_]*$',
102
+ }],
103
+ },
104
+ }
105
+ ```
106
+
107
+ ### Patterns run against script-controlled text
108
+
109
+ `allowPattern` is tested against every variable name the linted script assigns in a function, and
110
+ the script's author chooses that text. When you lint scripts you do not control, a pattern with
111
+ nested or overlapping quantifiers can make the regular expression engine backtrack for a time that
112
+ grows exponentially with the name's length. Write anchored patterns with one level of repetition
113
+ over a character class.
114
+
115
+ The rule rejects the most common accident: a pattern that repeats a group which itself repeats
116
+ (`*`, `+`, `{n,}` or `{n,m}` with `m > 1` applied to such a group). It is not a proof that an
117
+ accepted pattern is safe. Review every pattern you accept as if the guard were not there.
118
+
119
+ **What it does not catch.** These pass the check and can still be slow on a long name. Timings are
120
+ from Node 24 on a busy laptop and vary by machine; the shape of the growth is what matters.
121
+
122
+ - Alternation branches that overlap under a repetition grow exponentially. `^([A-Z]|[A-Z0-9])+_KEY$`
123
+ took about 0.4 s on a 24-character name, 1.2 s at 26 and 4.7 s at 28. `(a|a)+$` took 1.6 s at
124
+ 24 characters and 6.4 s at 26. `^(A|AA)*$` is the same shape.
125
+ - Adjacent quantifiers that can match the same text grow polynomially. `.*.*.*x` took 0.5 s on a
126
+ 255-character name and 0.9 s at 300; each extra `.*` raises the exponent. Nothing bounds the length
127
+ of a name or flag in a script, so a long enough one is slow.
128
+
129
+ **What it rejects that is in fact linear.** The check does not look at what the repeated group can
130
+ match, so it also refuses some patterns that are safe, such as `(a*b)*`,
131
+ `^(?:[a-z]+-)*[a-z]+$` and `^(?:SECRET|TOKEN|PASSWORD)(?:_[A-Z]+)*$`. A rewrite without the nesting
132
+ exists for some of these and not for others, so if you cannot rewrite a pattern exactly, loosen it to
133
+ the character class you mean. The last one can be rewritten exactly with a lookahead
134
+ (it accepts the same names; compared on every suffix of up to seven characters over `A`, `_`, `a`
135
+ and `1` after `SECRET`, `TOKEN` and `PASSWORD`):
136
+
137
+ ```js
138
+ { allowPattern: '^(?:SECRET|TOKEN|PASSWORD)(?!.*_(?:_|$))(?:_[A-Z][A-Z_]*)?$' }
139
+ ```
140
+
141
+
142
+ **When you see the error.** The error is thrown from the rule's `create()`, which ESLint calls for
143
+ each file it lints, so the check does not run when the config is loaded. ESLint stops the run at the
144
+ first file it lints with the rule enabled and prints "Error while loading rule" followed by
145
+ "Occurred while linting <that file>" and a stack trace, and exits with code 2. A run that lints no
146
+ file with the rule enabled never sees it.
147
+
148
+ The accident the check is for, and its fix:
149
+
150
+ ```js
151
+ // Rejected: `([A-Z]+_?)*` repeats a group that already repeats, so a long
152
+ // name that does not match can take seconds.
153
+ { allowPattern: '^([A-Z]+_?)*_KEY$' }
154
+
155
+ // Safe rewrite: one repetition over a character class.
156
+ { allowPattern: '^[A-Z0-9_]*_KEY$' }
157
+ ```
158
+
159
+ ## Examples
160
+
161
+ ### Incorrect
162
+
163
+ <!-- example: {"findings": 3, "messageIds": ["preferLocal", "preferLocal", "preferLocal"]} -->
164
+ ```sh
165
+ f() {
166
+ tmp=$(mktemp) # reported: tmp is global
167
+ read line # reported: read assigns line
168
+ for i in 1 2 3; do # reported: i is global
169
+ :
170
+ done
171
+ }
172
+ ```
173
+
174
+ <!-- example: {"findings": 1} -->
175
+ ```sh
176
+ f() {
177
+ x=1 # reported: the local comes after the assignment
178
+ local x
179
+ }
180
+ ```
181
+
182
+ ### Correct
183
+
184
+ ```sh
185
+ f() {
186
+ local tmp line i
187
+ tmp=$(mktemp)
188
+ read line
189
+ for i in 1 2 3; do
190
+ :
191
+ done
192
+ }
193
+ ```
194
+
195
+ ```sh
196
+ verbose=0 # assigned at top level before the function: an intentional global
197
+ f() {
198
+ verbose=1
199
+ COUNT=2 # UPPER_CASE is allowed by default
200
+ declare -g seen=1
201
+ export LEVEL=3
202
+ x=1 some-command # only the command's environment
203
+ }
204
+ ```
205
+
206
+ ## When not to use it
207
+
208
+ Turn this rule off for scripts that target POSIX `sh` (it is already inert there), or for scripts
209
+ that deliberately communicate through lower-case globals and don't want to list each one in
210
+ `allow`. Within a few functions, a directive comment can mark an intentional global.
211
+
212
+ ## Relationship to shellcheck
213
+
214
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
215
+ layer. Run shellcheck beside it.
216
+
217
+ shellcheck has no check for variables that leak out of a function. Its nearest checks are about
218
+ `local` itself: SC2155 flags `local x=$(cmd)` because it hides the command's exit status, which is
219
+ why this rule's fix declares the variable on a separate line, and SC2168 flags `local` outside a
220
+ function. This rule enforces the policy that function variables are declared `local` in the first
221
+ place.
@@ -0,0 +1,157 @@
1
+ # `sh/quote-expansions`
2
+
3
+ Require double quotes around a parameter expansion or command substitution in any word the shell
4
+ splits into fields and expands as a glob.
5
+
6
+ | Configs | Fixable | Languages |
7
+ | ------- | ------------ | --------- |
8
+ | `recommended` (`warn`), `agent-authored` (`error`) | suggestions | `sh/sh` |
9
+
10
+ ## Rule details
11
+
12
+ When a parameter expansion (`$var`, `${var}`, `$@`) or command substitution (`$(cmd)`,
13
+ `` `cmd` ``) is not inside double quotes, the shell splits its result on the characters in
14
+ `IFS` and then expands each piece as a glob pattern. With `file='my notes.txt'`, `rm $file`
15
+ removes `my` and `notes.txt`. With `pattern='*'`, `echo $pattern` lists the current directory.
16
+ Quoting the expansion (`rm "$file"`) keeps its result as one word, which is almost always what
17
+ the script means.
18
+
19
+ The rule reports each `ParamExp` and `CmdSubst` that is an unquoted part of a word in one of
20
+ these positions:
21
+
22
+ - a command word or argument of a simple command, including the operands of `[` / `test` and a
23
+ bare declaration operand such as `local $names` or `export $vars`;
24
+ - a word in a `for … in` or `select … in` list;
25
+ - an element of an array literal without a key, such as `arr=($a)`;
26
+ - a redirection target other than a here-document: `> $out`, `< $(pick)`, and the here-string
27
+ `<<< $x`, which bash split before version 4.4.
28
+
29
+ Each expansion is reported on its own, so `$a$b` reports twice. The rule does not report these
30
+ positions, which the shell does not split or glob:
31
+
32
+ - the operands of `[[ ]]`;
33
+ - the value of an assignment: `x=$y`, `local x=$y`, `declare x=$y`, `export x=$y`, a prefix
34
+ assignment such as `LC_ALL=$loc sort`, and a keyed array element such as `arr=([k]=$v)`;
35
+ - the subject word and the patterns of a `case`;
36
+ - arithmetic: `$(( ))`, `(( ))`, `let`, and array indexes;
37
+ - the special parameters that always expand to one word without glob characters (`$#`, `$?`,
38
+ `$$`, `$!`, `$-`), and length expansions such as `${#name}`;
39
+ - under `dialect: "bash"` and `dialect: "bats"`, the Bash variables that always hold an integer:
40
+ `$RANDOM`, `$SECONDS`, `$LINENO`, `$PPID`, `$OPTIND`, and `$BASH_SUBSHELL`;
41
+ - here-document delimiters and bodies;
42
+ - expansions already inside double quotes, and an expansion nested inside another parameter
43
+ expansion (`${a:-$b}` is judged as the outer expansion).
44
+
45
+ `"$@"` is clean. Unquoted `$@`, `$*`, and `${arr[@]}` report. An indirect expansion such as
46
+ `${!#}` (the last positional parameter) reports, because its value is arbitrary text.
47
+
48
+ The Bash integer variables are exempt only as a bare reference (`$RANDOM`, `${RANDOM}`); an
49
+ operator that substitutes other text (`${RANDOM/1/ }`) reports. Bash keeps each of them an integer
50
+ however a script assigns it or the environment supplies it, and each exists in every Bash from 3.2.
51
+ A digit in `IFS` splits these values, the same as `$?` and `$#`. They still report under
52
+ `dialect: "posix"`, where a shell such as dash treats them as ordinary variables. `$BASHPID`,
53
+ `$EPOCHSECONDS`, and `$SRANDOM` are not exempt: a Bash older than 4.0, 5.0, or 5.1 leaves them
54
+ unset, and an unset unquoted variable expands to no word at all. A script that unsets one of these
55
+ variables, or declares it `local`, removes its integer behavior; the rule does not detect that.
56
+ `$UID` and `$EUID` are not exempt either: Bash imports them verbatim from the environment, so
57
+ `env UID='a *' bash -c 'echo "[$UID]"'` prints `[a *]`, and an unquoted reference can split and
58
+ glob.
59
+
60
+ The rule decides from where the expansion is written, not from what it holds at run time. It does
61
+ no dataflow analysis, so `n=3; seq $n` reports even though `$n` can never split.
62
+
63
+ The positions are the same under every `languageOptions.dialect`, with one exception. Under
64
+ `dialect: "zsh"`, the rule does not report parameter expansions, because zsh neither splits nor
65
+ globs an unquoted parameter by default. It still reports command substitutions under zsh, because
66
+ zsh does split those. The rule does not detect a zsh script that turns on `SH_WORD_SPLIT` or
67
+ `GLOB_SUBST` (for example with `setopt sh_word_split`), so such a script gets no reports for its
68
+ unquoted parameters.
69
+
70
+ ### Suggestion
71
+
72
+ The message says the expansion undergoes field splitting and pathname expansion. Field splitting
73
+ uses the characters in `IFS`, which are space, tab, and newline unless the script changes them.
74
+
75
+ Each report offers one suggestion: wrap the reported expansion, and only that part of the word, in
76
+ double quotes:
77
+
78
+ <!-- transform: {"kind": "suggestion"} -->
79
+
80
+ ```sh
81
+ cp x$a"y" dest
82
+ # becomes
83
+ cp x"$a""y" dest
84
+ ```
85
+
86
+ The rule never autofixes, because a
87
+ script can rely on splitting, for example `$CFLAGS` holding several compiler flags, and only its
88
+ author knows. A backquoted substitution whose text contains `"` or `\` is reported without a
89
+ suggestion: inside double quotes, `\` inside backquotes means something different, and POSIX
90
+ leaves `"` inside them undefined. Rewrite it as `"$(…)"` by hand.
91
+
92
+ ## Options
93
+
94
+ This rule has no options.
95
+
96
+ ```js
97
+ // eslint.config.js
98
+ {
99
+ rules: {
100
+ 'sh/quote-expansions': 'warn',
101
+ },
102
+ }
103
+ ```
104
+
105
+ ## Examples
106
+
107
+ ### Incorrect
108
+
109
+ <!-- example: {"findings": 9} -->
110
+ ```sh
111
+ rm $file # an argument: splits on spaces and globs
112
+ echo $(date) # a command substitution argument
113
+ $cmd --version # the command word splits too
114
+ [ $a = b ] # `[` is a command; its operands are arguments
115
+ for f in $files; do :; done # a for list
116
+ arr=($a) # an array literal element
117
+ echo hi > $out # a redirection target
118
+ echo $@ $* # unquoted $@ and $* split each argument again
119
+ ```
120
+
121
+ ### Correct
122
+
123
+ ```sh
124
+ rm "$file"
125
+ echo "$(date)"
126
+ for f in "${files[@]}"; do :; done
127
+ [[ $a == $b ]] # [[ ]] does not split its operands
128
+ x=$y # assignment values are not split
129
+ case $x in $pat) ;; esac # case words and patterns are not split
130
+ echo $(( n + 1 )) $# $? # arithmetic and single-word specials
131
+ sleep $RANDOM # a Bash integer variable (dialect: bash)
132
+ cat <<EOF
133
+ $x is not split inside a here-document body
134
+ EOF
135
+ exec "$@" # "$@" keeps every argument intact
136
+ ```
137
+
138
+ ## When not to use it
139
+
140
+ Disable the rule if you run shellcheck, which already reports the same problem as SC2086
141
+ (see below). For one line that relies on splitting on purpose, such as passing `$CFLAGS` to a
142
+ compiler, disable it on that line (`# eslint-disable-next-line sh/quote-expansions`) or rewrite
143
+ the value as an array (`"${cflags[@]}"`).
144
+
145
+ ## Relationship to shellcheck
146
+
147
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
148
+ layer. Run shellcheck beside it.
149
+
150
+ This rule is one of eslint-sh's two deliberate overlaps with shellcheck. It covers the same
151
+ problem as SC2086 (double quote to prevent globbing and word splitting), and SC2068 for unquoted
152
+ `$@`. It exists for teams that run eslint-sh without shellcheck, so that this common mistake is
153
+ still caught. **If you run shellcheck, disable this rule**, so the same line is not reported
154
+ twice. The rule does not try to match SC2086's heuristics: shellcheck skips some expansions it
155
+ can prove are safe, such as a variable it saw assigned a number, and this rule reports them
156
+ because it does no dataflow analysis. That is why `recommended` enables it at `warn` instead of
157
+ `error`.