@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
@@ -5,25 +5,29 @@ one of those selectors to a complete, runnable ESLint rule: a `{ meta, create(co
5
5
  a `context.report` call with a message and location, and the exact flat-config (`eslint.config.mjs`)
6
6
  wiring — `plugins`, `rules`, `language` — needed to run it.
7
7
 
8
- Every code block on this page is the literal, shipped file it names — not a paraphrase — and both
9
- files are executed verbatim in CI (`test/custom-rule-tutorial.test.ts` imports
10
- [`examples/no-rm-rf-rule.mjs`](./examples/no-rm-rf-rule.mjs) and
11
- [`examples/eslint.config.mjs`](./examples/eslint.config.mjs) directly and runs them through a real
12
- `eslint` `Linter.verify()`): the rule fires on a fixture script and does not fire on a clean one,
13
- so this tutorial cannot silently drift from working code. Both example files ship with this
14
- package (`docs/examples/`), so you can copy them into your own project as-is. Both use the `.mjs`
15
- extension deliberately: they are ES modules (`import`/`export`), and a default `npm init -y`
16
- project is CommonJS, so a plain `.js` extension would make Node — and ESLint's own config
17
- loader — try to parse them as CommonJS and fail with `SyntaxError: Cannot use import statement
18
- outside a module`. `.mjs` marks each file as an ES module regardless of the surrounding project's
19
- `"type"` field.
8
+ Every rule on this page is built with `defineShellRule`, the plugin's rule-authoring helper (step
9
+ 2 explains what it does). Every code block that names a file is the literal, shipped file it
10
+ names, not a paraphrase, and every one is executed by this package's own test suite, which imports
11
+ the files in [`examples/`](./examples/) directly and runs them through a real `eslint`
12
+ `Linter.verify()`, and type-checks the TypeScript example in step 10 against the published
13
+ declarations. A rule fires on a fixture script and does not fire on a clean one, so this tutorial
14
+ cannot silently drift from working code. To write the same kind of test for your own rule, see
15
+ [step 11](#11-test-your-rule), whose example [`no-backticks-rule-tester.mjs`](./examples/no-backticks-rule-tester.mjs)
16
+ you can run yourself. All example files ship with this package
17
+ (`docs/examples/`), so you can copy them into your own project as-is. The JavaScript examples use
18
+ the `.mjs` extension deliberately: they are ES modules (`import`/`export`), and a default
19
+ `npm init -y` project is CommonJS, so a plain `.js` extension would make Node — and ESLint's own
20
+ config loader — try to parse them as CommonJS and fail with `SyntaxError: Cannot use import
21
+ statement outside a module`. `.mjs` marks each file as an ES module regardless of the surrounding
22
+ project's `"type"` field.
20
23
 
21
24
  ## 1. Pick a selector from the cookbook
22
25
 
23
26
  The cookbook's `:command` [launch class](./selectors.md#the-six-launch-classes) matches every
24
27
  `CallExpr` node — every shell command invocation. That's the selector this tutorial's rule listens
25
- on; `create()` then narrows further (is the command `rm`? does it pass a combined `-rf`/`-fr`
26
- flag?) the same way a hand-written rule narrows any other selector-first ESLint rule.
28
+ on; `create()` then narrows further (is the command `rm`? does it pass recursive and force short
29
+ flags, such as `-rf`, `-fr`, or `-r -f`?) the same way a hand-written rule narrows any other
30
+ selector-first ESLint rule.
27
31
 
28
32
  ## 2. Write the rule module
29
33
 
@@ -34,10 +38,13 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
34
38
  * The complete "flag `rm -rf`" custom rule from the custom-rule tutorial
35
39
  * (`../custom-rule-tutorial.md`). Plain, directly-runnable JavaScript (no
36
40
  * compile step) — this file *is* the tutorial's rule-module code fence,
37
- * copy-paste-verbatim; `test/custom-rule-tutorial.test.ts` imports this
41
+ * copy-paste-verbatim; this package's test suite imports this
38
42
  * exact file (not a reconstruction) and asserts the markdown fence matches
39
43
  * it byte-for-byte, so the two can't silently drift.
40
44
  *
45
+ * Built with `defineShellRule`, which declares `meta.languages: ['sh/sh']`
46
+ * for it, so ESLint refuses to run the rule on a file of any other language.
47
+ *
41
48
  * Built on the `:command` launch class from the selector cookbook
42
49
  * (`../selectors.md`) — `:command` matches every `CallExpr`, so `create()`
43
50
  * narrows further by inspecting the command word and its flag arguments,
@@ -48,12 +55,16 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
48
55
  *
49
56
  * Flag words are resolved with `@eslint-sh/eslint-plugin`'s view-aware
50
57
  * `resolveWord` (backed by `sh-ast/analyze`) rather than a
51
- * hand-rolled scan: a word is only ever treated as the literal text
52
- * `-rf`/`-fr` when it is *statically* that string (bare or quoted literal
53
- * concatenations); a word containing a `ParamExp`/`CmdSubst`/`ArithmExp`/
54
- * `ProcSubst` resolves to `null` ("unknown") rather than being silently
55
- * erased into a static match or a static non-match — the same static-word
56
- * contract `design/PACKAGES.md` documents for `resolveWordText`. Importing
58
+ * hand-rolled scan: a word is only treated as a short-option cluster
59
+ * when it is *statically* that string (bare or
60
+ * quoted literal concatenations). Recursive (`r`/`R`) and force (`f`)
61
+ * flags accumulate across words before `--`; uppercase `F` is not force.
62
+ * Options are matched anywhere before `--`, GNU-style, even after operands.
63
+ * This teaching example does not model long options or option precedence.
64
+ * A word containing a `ParamExp`/`CmdSubst`/`ArithmExp`/
65
+ * `ProcSubst` resolves to `{ static: false, reason }` (`'expansion'`,
66
+ * `'tilde'`, `'glob'`, …) rather than being silently erased into a static
67
+ * match — see `resolveWord`'s API docs. Importing
57
68
  * it from the plugin package (rather than `sh-ast/analyze` directly) means
58
69
  * a project that installs only `@eslint-sh/eslint-plugin` has everything
59
70
  * this rule needs, with no separate `sh-ast` install or npm-hoisting
@@ -62,13 +73,13 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
62
73
  * @see ../custom-rule-tutorial.md — the tutorial this rule backs
63
74
  * @see ../selectors.md — the `:command` launch class
64
75
  */
65
- import { resolveWord } from '@eslint-sh/eslint-plugin';
76
+ import { defineShellRule, resolveWord } from '@eslint-sh/eslint-plugin';
66
77
 
67
- const noRmRf = {
78
+ const noRmRf = defineShellRule({
68
79
  meta: {
69
80
  type: 'problem',
70
81
  docs: {
71
- description: 'disallow `rm -rf`/`rm -fr` invocations',
82
+ description: 'disallow recursive-force `rm` invocations',
72
83
  },
73
84
  schema: [],
74
85
  messages: {
@@ -94,11 +105,15 @@ const noRmRf = {
94
105
  const endOfOptions = words.indexOf('--');
95
106
  const flagCandidates = words.slice(1, endOfOptions === -1 ? undefined : endOfOptions);
96
107
 
97
- const isRecursiveForce = flagCandidates.some(
98
- (word) =>
99
- word !== null &&
100
- (/^-[a-z]*r[a-z]*f[a-z]*$/i.test(word) || /^-[a-z]*f[a-z]*r[a-z]*$/i.test(word)),
108
+ // Only static short-option clusters contribute flags; operands,
109
+ // long options, and unresolved words supply no evidence. Recursive
110
+ // and force may occur in the same word or in separate words.
111
+ const shortOptions = flagCandidates.filter(
112
+ (word) => word !== null && /^-[a-zA-Z]+$/.test(word),
101
113
  );
114
+ const hasRecursive = shortOptions.some((word) => /[rR]/.test(word));
115
+ const hasForce = shortOptions.some((word) => word.includes('f'));
116
+ const isRecursiveForce = hasRecursive && hasForce;
102
117
 
103
118
  if (isRecursiveForce) {
104
119
  context.report({ node, loc: node.loc, messageId: 'rmRf' });
@@ -106,13 +121,25 @@ const noRmRf = {
106
121
  },
107
122
  };
108
123
  },
109
- };
124
+ });
110
125
 
111
126
  export default noRmRf;
112
127
  ```
113
128
 
129
+ `defineShellRule` takes the `{ meta, create(context) }` object you would write for any ESLint rule
130
+ and returns it as an ordinary ESLint rule, with one addition: `meta.languages: ['sh/sh']`. That
131
+ declaration is what makes ESLint refuse to run the rule on a file of another language. Without it,
132
+ a rule enabled for a file that ESLint treats as JavaScript (typically because its config entry is
133
+ missing `language: 'sh/sh'`) runs without error and never matches, so nothing tells you it was
134
+ misconfigured. With it, ESLint stops with a configuration error naming the rule:
135
+ `The following rules do not support the language "js/js"`. The helper always sets the field, so a
136
+ rule built with it can't forget it, and in TypeScript writing `meta.languages` yourself is a
137
+ compile error. Everything else in `meta`, including your own `docs.url`, is kept as you wrote it.
138
+
114
139
  A few things worth calling out:
115
140
 
141
+ - `defineShellRule` and `resolveWord` both come from `@eslint-sh/eslint-plugin` (or the umbrella
142
+ `eslint-sh`). A rule needs no other package: not `sh-ast`, and not `@eslint/core`.
116
143
  - The listener key, `':command'`, is a selector string — one of the [six launch
117
144
  classes](./selectors.md#the-six-launch-classes) — not a bare node-type name. ESLint's
118
145
  `esquery`-based rule-listener matching (via `ShellLanguage.matchesSelectorClass`) treats it
@@ -123,6 +150,12 @@ A few things worth calling out:
123
150
  quoted literal (`rm`, `'r'm`, `"-rf"` all resolve), and `{ static: false, reason }` the moment any
124
151
  part of the word depends on a `ParamExp`/`CmdSubst`/`ArithmExp`/`ProcSubst` — never inventing a
125
152
  value for a dynamic word.
153
+ - Short flags accumulate across static words before the first `--`: `rm -rf build`,
154
+ `rm -r -f build`, and `rm -R -f build` all report, while `rm -r build`, `rm -f build`,
155
+ and `rm -r -- -f` do not. Recursive accepts `r` or `R`; force is lowercase `f`, so `-rF`
156
+ does not supply both flags. Options are matched anywhere before `--`, GNU-style, even after
157
+ operands (for example, `rm build -rf` reports). This example focuses on short options; it does not
158
+ model long options or option precedence (such as a later `-i` overriding `-f`).
126
159
  - `context.report({ node, loc: node.loc, messageId: 'rmRf' })` reports with both a message (via
127
160
  `meta.messages.rmRf`) and an explicit location (`node.loc`, `ShellSourceCode`'s
128
161
  `{ start: { line, column }, end: { line, column } }` shape) — the two pieces every ESLint lint
@@ -136,7 +169,7 @@ A few things worth calling out:
136
169
  // eslint.config.mjs
137
170
  //
138
171
  // The custom-rule tutorial's exact flat-config wiring
139
- // (`../custom-rule-tutorial.md`, step 3). `test/custom-rule-tutorial.test.ts`
172
+ // (`../custom-rule-tutorial.md`, step 3). This package's test suite
140
173
  // imports this file's default export directly and runs it through a real
141
174
  // `eslint` `Linter.verify()` — not a reconstruction of this shape — so the
142
175
  // documented wiring is genuinely tested.
@@ -145,7 +178,8 @@ import noRmRf from './no-rm-rf-rule.mjs';
145
178
 
146
179
  export default [
147
180
  {
148
- files: ['**/*.sh', '**/*.bash'],
181
+ // Extensionless scripts need `bin/?*`: ESLint never lints a file matched only by `bin/*` or `bin/**`.
182
+ files: ['**/*.sh', '**/*.bash', 'bin/?*'],
149
183
  plugins: {
150
184
  sh,
151
185
  'my-rules': { rules: { 'no-rm-rf': noRmRf } },
@@ -158,6 +192,9 @@ export default [
158
192
  ];
159
193
  ```
160
194
 
195
+ - `files` covers `.sh`/`.bash` files and the extensionless scripts directly in `bin/`. Use
196
+ `bin/?*`, not `bin/*` or `bin/**`: ESLint skips a file that only a pattern ending in `*` or
197
+ `/**` matches. The `eslint-sh` README's Troubleshooting section lists other patterns that work.
161
198
  - `plugins.sh` supplies `languages.sh` (the `ShellLanguage`/`ShellSourceCode` core) — required for
162
199
  `language: 'sh/sh'` to resolve.
163
200
  - `plugins['my-rules']` is a second, minimal plugin object whose only job is to register the rule
@@ -174,7 +211,716 @@ rm -rf "$BUILD_DIR"
174
211
  ```
175
212
 
176
213
  the rule reports once, at the `CallExpr`'s location, with the message text from
177
- `meta.messages.rmRf`. Given a clean script with no `rm -rf`/`rm -fr` invocation, the rule produces
178
- no messages at all — both cases are asserted in `test/custom-rule-tutorial.test.ts` against a real
179
- `eslint` `Linter.verify()` run (not a hand-built approximation of one), using the real
214
+ `meta.messages.rmRf`. Given a clean script with no `rm` invocation combining recursive and force
215
+ short flags, the rule produces no messages at all — both cases are asserted in
216
+ this package's test suite against a real `eslint` `Linter.verify()` run (not a hand-built approximation of one), using the real
180
217
  `docs/examples/eslint.config.mjs` shown in step 3.
218
+
219
+ ## 5. Every command in the file
220
+
221
+ The `:command` listener above sees one `CallExpr` at a time. Some rules need every command in the
222
+ file together with how each one is reached: inside which function, inside `$(…)`, in which
223
+ pipeline stage. `context.sourceCode.getCommandSites()` returns that list, one entry per command in
224
+ source order, from `enumerateCommands` (in `@eslint-sh/eslint-plugin/syntax/analyze`). Each
225
+ entry has:
226
+
227
+ - `node`: the `CallExpr` node, the same object a `CallExpr` listener receives, so you can report
228
+ on it.
229
+ - `context`: the frames that lead to the command, outermost first. `date` in
230
+ `f() { echo "$(date)"; }` has `[{ kind: 'function', name: 'f' }, { kind: 'cmdSubst' }]`.
231
+ - `argv0` and `argv`: the command's words, resolved like `resolveWord` resolves them.
232
+ - `resolveInvokedCommand()`: the command that actually runs after transparent wrappers such as
233
+ `sudo` or `env`. Its `chainIndices` say where each word of that chain sits in the site's `argv`,
234
+ so the command's own arguments are `site.argv.slice(chainIndices.at(-1) + 1)`. `site.argv` is
235
+ the whole command line, wrappers included, so slicing from `0` or `1` would hand you
236
+ `sudo`'s arguments for `sudo chmod 777 x`.
237
+
238
+ An assignment-only statement such as `FOO=1` runs no command and has no entry.
239
+
240
+ `docs/examples/no-curl-in-functions-rule.mjs` reports `curl` anywhere inside a shell function:
241
+
242
+ ```mjs
243
+ /**
244
+ * The "every command in the file" custom rule from the custom-rule tutorial
245
+ * (`../custom-rule-tutorial.md`, step 5). Plain, directly-runnable
246
+ * JavaScript; this package's test suite imports this exact file and
247
+ * asserts the tutorial's code fence matches it byte-for-byte.
248
+ *
249
+ * It reports `curl` run anywhere inside a shell function, including through a
250
+ * wrapper (`sudo curl …`) or inside a command substitution in that function.
251
+ * Instead of tracking "am I inside a function?" while visiting nodes, it reads
252
+ * every command once from `context.sourceCode.getCommandSites()`, whose
253
+ * `context` frames (outermost first) already say how each command is reached.
254
+ *
255
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
256
+ */
257
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
258
+
259
+ const noCurlInFunctions = defineShellRule({
260
+ meta: {
261
+ type: 'suggestion',
262
+ docs: {
263
+ description: 'disallow `curl` inside shell functions',
264
+ },
265
+ schema: [],
266
+ messages: {
267
+ curlInFunction: 'Function `{{name}}` runs `curl`; fetch data before calling it.',
268
+ },
269
+ },
270
+ create(context) {
271
+ return {
272
+ File() {
273
+ for (const site of context.sourceCode.getCommandSites()) {
274
+ const { effective } = site.resolveInvokedCommand();
275
+ if (!effective.static || effective.text !== 'curl') {
276
+ continue;
277
+ }
278
+
279
+ // Frames run outermost first, so the last `function` frame is the
280
+ // function that directly contains this command.
281
+ const fn = site.context.findLast((frame) => frame.kind === 'function');
282
+ if (fn) {
283
+ context.report({
284
+ node: site.node,
285
+ messageId: 'curlInFunction',
286
+ data: { name: fn.name },
287
+ });
288
+ }
289
+ }
290
+ },
291
+ };
292
+ },
293
+ });
294
+
295
+ export default noCurlInFunctions;
296
+ ```
297
+
298
+ Register it the same way as the rule in step 3. For
299
+
300
+ ```sh
301
+ fetch() {
302
+ sudo curl -fsSL "$1"
303
+ }
304
+ curl -fsSL https://example.com
305
+ ```
306
+
307
+ it reports once, on line 2: the `curl` in `fetch` runs through `sudo`, and the top-level `curl` is
308
+ outside any function. This package's test suite runs this case through a real `eslint`
309
+ `Linter`.
310
+
311
+ ## 6. Report on an operator
312
+
313
+ Lint view nodes carry a `range` and `loc` for the whole node, but not the positions of the tokens
314
+ inside it, such as the `&&` of a `BinaryCmd`. Those live on the original sh-ast record:
315
+
316
+ - `context.sourceCode.getSyntax(node)` returns the sh-ast record a view node was parsed from, with
317
+ upstream field names (`X`, `Y`, `OpPos`, …). Treat it as read-only: every rule and helper linting
318
+ the file shares it.
319
+ - `context.sourceCode.getIndexFromSyntaxPos(pos)` and `getLocFromSyntaxPos(pos)` convert one of that
320
+ record's positions into the UTF-16 index and one-based location ESLint uses. sh-ast positions count
321
+ UTF-8 bytes, so use these instead of the raw `Offset` or `Col` whenever text before the position
322
+ might not be ASCII.
323
+ - `context.sourceCode.getViewNode(record)` goes the other way, from a record back to the view node
324
+ you can pass to `context.report({ node })`.
325
+
326
+ `docs/examples/no-and-or-as-if-rule.mjs` reports `A && B || C`, which reads like if-then-else but
327
+ also runs `C` when `B` fails, and puts the report on the `&&`:
328
+
329
+ ```mjs
330
+ /**
331
+ * The "report on an operator" custom rule from the custom-rule tutorial
332
+ * (`../custom-rule-tutorial.md`, step 6). Plain, directly-runnable
333
+ * JavaScript; this package's test suite imports this exact file and
334
+ * asserts the tutorial's code fence matches it byte-for-byte.
335
+ *
336
+ * It reports `A && B || C`, which reads like if-then-else but is not: `C`
337
+ * also runs when `A` succeeds and `B` fails. The report sits on the `&&`
338
+ * itself. The lint view has no operator positions, so the rule reads the
339
+ * original sh-ast record with `getSyntax` and converts its byte position
340
+ * `OpPos` to ESLint coordinates with `getIndexFromSyntaxPos` and
341
+ * `getLocFromSyntaxPos`.
342
+ *
343
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
344
+ */
345
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
346
+
347
+ const noAndOrAsIf = defineShellRule({
348
+ meta: {
349
+ type: 'problem',
350
+ docs: {
351
+ description: 'disallow `A && B || C` used as if-then-else',
352
+ },
353
+ schema: [],
354
+ messages: {
355
+ andOr: '`A && B || C` is not if-then-else: C also runs when B fails. Use `if A; then B; else C; fi`.',
356
+ },
357
+ },
358
+ create(context) {
359
+ const { sourceCode } = context;
360
+
361
+ /** The two-character operator a `BinaryCmd` record's `OpPos` points at. */
362
+ const operatorOf = (binaryCmd) => {
363
+ const start = sourceCode.getIndexFromSyntaxPos(binaryCmd.OpPos);
364
+ return sourceCode.text.slice(start, start + 2);
365
+ };
366
+
367
+ return {
368
+ BinaryCmd(node) {
369
+ // `a && b || c` parses as `(a && b) || c`: the `||` command's left
370
+ // statement holds the `&&` command.
371
+ const or = sourceCode.getSyntax(node);
372
+ const and = or.X?.Cmd;
373
+ if (operatorOf(or) !== '||' || and?.Type !== 'BinaryCmd' || operatorOf(and) !== '&&') {
374
+ return;
375
+ }
376
+
377
+ const start = sourceCode.getIndexFromSyntaxPos(and.OpPos);
378
+ context.report({
379
+ loc: {
380
+ start: sourceCode.getLocFromSyntaxPos(and.OpPos),
381
+ end: sourceCode.getLocFromIndex(start + 2),
382
+ },
383
+ messageId: 'andOr',
384
+ });
385
+ },
386
+ };
387
+ },
388
+ });
389
+
390
+ export default noAndOrAsIf;
391
+ ```
392
+
393
+ Register it the same way as the rule in step 3. For
394
+
395
+ ```sh
396
+ [ -f "$f" ] && rm "$f" || echo missing
397
+ ```
398
+
399
+ it reports once, on line 1 from column 13 to column 15: exactly the `&&`. A plain `A && B` or
400
+ `A || B` is not reported. This package's test suite runs this case through a real
401
+ `eslint` `Linter`.
402
+
403
+ ## 7. Using sh-ast's own operations and types
404
+
405
+ The records `getSyntax` returns are sh-ast records. A rule that needs sh-ast's own operations or
406
+ types for them (to `walk` a record, print an edited copy with `printSync`, run `enumerateCommands`
407
+ over a tree, or name a record type such as `BinaryCmd`) imports them from the plugin's subpaths,
408
+ never from `sh-ast` directly:
409
+
410
+ - `@eslint-sh/eslint-plugin/syntax` (or `eslint-sh/syntax`) has the complete public surface of
411
+ `sh-ast`: `parseSync`, `walk`, `printSync`, `PrintOptions`, and the record types such as `Node`,
412
+ `Word`, `CallExpr`, `SyntaxPos` and `GoString`.
413
+ - `@eslint-sh/eslint-plugin/syntax/analyze` (or `eslint-sh/syntax/analyze`) has the complete
414
+ public surface of `sh-ast/analyze`: `enumerateCommands`, `resolveArgv0` (its `chainIndices`
415
+ locate a resolved command's arguments in the site's `argv`), `DEFAULT_TRANSPARENT_WRAPPERS`,
416
+ `WrapperSpec`, the named operator constants with `rawOperator`, and a raw `resolveWord` that
417
+ takes a sh-ast `Node` rather than a lint-view `ShellNode`.
418
+
419
+ Both come from the plugin's own `sh-ast` dependency, the same module instance the language parses
420
+ with, so you don't install `sh-ast` and its version always matches the records `getSyntax` returns.
421
+ They track the plugin's pinned `sh-ast` minor: an `sh-ast` 0.x minor bump is a minor release of the
422
+ plugin.
423
+
424
+ ## 8. Printed fixes
425
+
426
+ Some fixes are easier to describe as an edit to the syntax than as new text. Turning
427
+ `` `date` `` into `$(date)` by hand means re-escaping any nested backticks and backslashes; on the
428
+ syntax it is one field, `CmdSubst.Backquotes`. Two `context.sourceCode` methods turn such an edit
429
+ into an ESLint fix:
430
+
431
+ - `getEditableSyntax(node)` returns an independent copy of the sh-ast record `node` was parsed
432
+ from. It is an ordinary `structuredClone` of that record, which sh-ast documents as the supported
433
+ way to get an editable copy. Edit the copy freely: the original record that `getSyntax` returns,
434
+ the lint view, and helpers such as `resolveWord` keep seeing the original source. It throws a
435
+ `TypeError` for a node that has no record of its own, such as one you built yourself.
436
+ - `getPrintedReplacement(node, edited, options)` prints `edited` with sh-ast's `printSync` and
437
+ returns `{ range, text }`, ready for `fixer.replaceTextRange(range, text)`. The range is
438
+ `node.range`, or the whole file when `edited` is a `File`. `edited` must be a root `printSync`
439
+ accepts (`File`, `Stmt`, `Word`, `Assign`, a `Command` or a `WordPart`), and output that is not
440
+ valid UTF-8 throws a `TypeError` instead of becoming a fix.
441
+
442
+ `docs/examples/no-backticks-rule.mjs` reports backtick command substitution and fixes it to `$(…)`:
443
+
444
+ ```mjs
445
+ /**
446
+ * The "printed fix" custom rule from the custom-rule tutorial
447
+ * (`../custom-rule-tutorial.md`, step 8). Plain, directly-runnable
448
+ * JavaScript; this package's test suite imports this exact file and
449
+ * asserts the tutorial's code fence matches it byte-for-byte.
450
+ *
451
+ * It reports backtick command substitution (`` `date` ``) and fixes it to
452
+ * `$(date)`. Instead of rewriting text by hand, which would have to re-escape
453
+ * nested backticks and backslashes, the fix edits a copy of the substitution's
454
+ * sh-ast record from `getEditableSyntax` and lets sh-ast's printer write the
455
+ * new text with `getPrintedReplacement`. The original syntax every other rule
456
+ * reads stays unchanged.
457
+ *
458
+ * @import { PrintOptions } from '@eslint-sh/eslint-plugin/syntax'
459
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
460
+ */
461
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
462
+
463
+ /**
464
+ * Prints nested lines with two spaces per level instead of the printer's
465
+ * default tabs.
466
+ * @type {PrintOptions}
467
+ */
468
+ const printOptions = { Indent: 2 };
469
+
470
+ const noBackticks = defineShellRule({
471
+ meta: {
472
+ type: 'suggestion',
473
+ fixable: 'code',
474
+ docs: {
475
+ description: 'prefer `$(…)` over backtick command substitution',
476
+ },
477
+ schema: [],
478
+ messages: {
479
+ backticks: 'Use `$(…)` instead of backticks for command substitution.',
480
+ },
481
+ },
482
+ create(context) {
483
+ const { sourceCode } = context;
484
+ return {
485
+ CmdSubst(node) {
486
+ if (!node.backquotes) {
487
+ return;
488
+ }
489
+ // An independent copy: editing it changes neither the lint view nor
490
+ // the syntax other rules read.
491
+ const copy = sourceCode.getEditableSyntax(node);
492
+ copy.Backquotes = false;
493
+ const { range, text } = sourceCode.getPrintedReplacement(node, copy, printOptions);
494
+ context.report({
495
+ node,
496
+ messageId: 'backticks',
497
+ fix: (fixer) => fixer.replaceTextRange(range, text),
498
+ });
499
+ },
500
+ };
501
+ },
502
+ });
503
+
504
+ export default noBackticks;
505
+ ```
506
+
507
+ Register it the same way as the rule in step 3. For
508
+
509
+ ```sh
510
+ now=`date +%s`
511
+ echo "uid: `id -u` 🎉"
512
+ ```
513
+
514
+ it reports both substitutions, and `eslint --fix` rewrites the file to
515
+
516
+ ```sh
517
+ now=$(date +%s)
518
+ echo "uid: $(id -u) 🎉"
519
+ ```
520
+
521
+ leaving every other character of the file as it was. This package's test suite runs this
522
+ case through a real `eslint` `Linter`.
523
+
524
+ The printer formats the node it prints; it does not copy the original text. Its output starts at
525
+ column 0, and nested lines are indented with tabs, or with `Indent` spaces when `options.Indent` is
526
+ set, as the example does. A `Word` or `WordPart` root, like the `CmdSubst` above, is usually a
527
+ single line, so the fix changes only that node. Replacing a multi-line root nested inside other
528
+ code, such as an `if` statement in a function body, keeps the shell meaning but may change the
529
+ formatting of its lines:
530
+
531
+ ```sh
532
+ f() {
533
+ if true; then
534
+ echo new
535
+ fi
536
+ }
537
+ ```
538
+
539
+ Prefer `Word` and `WordPart` roots for surgical fixes. For a `Stmt` root, the printed text leaves
540
+ out the statement's own leading and trailing comments, which lie outside its range, and keeps its
541
+ `;` terminator, so `a; b` never becomes `a b`. When the edit is to a few tokens of a larger,
542
+ hand-formatted construct, step 9's position-based text edit is the better tool.
543
+
544
+ `PrintOptions`, `Node` and the record types (`CmdSubst`, `Word`, …) are the types of
545
+ `@eslint-sh/eslint-plugin/syntax` (step 7), and so is `printSync`, if you want to print a record
546
+ yourself, for example to compare it with the source before reporting:
547
+
548
+ ```ts
549
+ import { printSync } from '@eslint-sh/eslint-plugin/syntax';
550
+ import type { Node, PrintOptions } from '@eslint-sh/eslint-plugin/syntax';
551
+ ```
552
+
553
+ ## 9. Position-based text edits
554
+
555
+ A printed fix replaces a node's whole range with the printer's rendering of it. That is the right
556
+ trade when the new text is hard to write by hand, as with re-escaping nested backticks. It is the
557
+ wrong one when the edit touches a few tokens of a node that spans several lines, because the
558
+ printer reflows everything else in that range:
559
+
560
+ - nested lines, `\` continuation lines included, are re-indented with tabs by default or
561
+ `options.Indent` spaces, whatever the original used;
562
+ - spacing the printer has its own opinion about is normalized, such as the gap before a trailing
563
+ comment;
564
+ - every line in the range changes, so the fix shows up in a diff as a rewrite of the whole node.
565
+
566
+ Use a position-based text edit instead whenever you know which characters to change: replace or
567
+ remove just that span with `fixer.replaceTextRange` or `fixer.removeRange`, and leave the rest of
568
+ the file byte-for-byte as it was. The positions come from the sh-ast record (step 6):
569
+ `getSyntax(node)` gives the record, and `getIndexFromSyntaxPos(pos)` converts each of its
570
+ UTF-8-byte positions into the string offset a fixer takes.
571
+
572
+ `docs/examples/no-function-keyword-rule.mjs` rewrites the bash-only `function name {` to the
573
+ portable `name() {` by replacing only the text from `function` through the name:
574
+
575
+ ```mjs
576
+ /**
577
+ * The "position-based text edit" custom rule from the custom-rule tutorial
578
+ * (`../custom-rule-tutorial.md`, step 9). Plain, directly-runnable
579
+ * JavaScript; this package's test suite imports this exact file and
580
+ * asserts the tutorial's code fence matches it byte-for-byte.
581
+ *
582
+ * It reports the bash-only `function name {` form and fixes it to the
583
+ * portable `name() {`. The fix replaces only the text from the `function`
584
+ * keyword through the name, so the body keeps its exact layout, comments and
585
+ * all; printing the whole edited function would re-indent the body instead.
586
+ * The two ends of that text come from the sh-ast record's own positions,
587
+ * converted to string offsets with `getIndexFromSyntaxPos` (sh-ast positions
588
+ * count UTF-8 bytes).
589
+ *
590
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
591
+ */
592
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
593
+
594
+ const noFunctionKeyword = defineShellRule({
595
+ meta: {
596
+ type: 'suggestion',
597
+ fixable: 'code',
598
+ docs: {
599
+ description: 'prefer `name() { … }` over the `function` keyword',
600
+ },
601
+ schema: [],
602
+ messages: {
603
+ useParens: 'Declare `{{name}}` as `{{name}}() { … }`, without the `function` keyword.',
604
+ },
605
+ },
606
+ create(context) {
607
+ const { sourceCode } = context;
608
+ return {
609
+ FuncDecl(node) {
610
+ const decl = sourceCode.getSyntax(node);
611
+ // Only `function name {`: not `function name() {`, `name() {`, or
612
+ // zsh's several-name form.
613
+ if (!decl.RsrvWord || decl.Parens || decl.Name === null || decl.Names !== null) {
614
+ return;
615
+ }
616
+ // A name that is not valid UTF-8 arrives as bytes, not a string.
617
+ const name = decl.Name.Value;
618
+ if (typeof name !== 'string') {
619
+ return;
620
+ }
621
+ const start = sourceCode.getIndexFromSyntaxPos(decl.Position);
622
+ const end = sourceCode.getIndexFromSyntaxPos(decl.Name.ValueEnd);
623
+ context.report({
624
+ node,
625
+ messageId: 'useParens',
626
+ data: { name },
627
+ fix: (fixer) => fixer.replaceTextRange([start, end], `${name}()`),
628
+ });
629
+ },
630
+ };
631
+ },
632
+ });
633
+
634
+ export default noFunctionKeyword;
635
+ ```
636
+
637
+ Register it the same way as the rule in step 3. For
638
+
639
+ ```sh
640
+ function greet {
641
+ # Say hello.
642
+ printf '%s\n' \
643
+ "hello, $1" # to stdout
644
+ }
645
+ ```
646
+
647
+ `eslint --fix` changes only the first line:
648
+
649
+ ```sh
650
+ greet() {
651
+ # Say hello.
652
+ printf '%s\n' \
653
+ "hello, $1" # to stdout
654
+ }
655
+ ```
656
+
657
+ The printed-fix version of the same rule (flip `RsrvWord` and `Parens` on a `getEditableSyntax`
658
+ copy, then `getPrintedReplacement(node, copy, { Indent: 4 })`) replaces the whole function, and
659
+ even with `Indent` matching the source it does not keep the original layout (with the default
660
+ options, the four-space indentation would also become tabs):
661
+
662
+ ```sh
663
+ greet() {
664
+ # Say hello.
665
+ printf '%s\n' \
666
+ "hello, $1" # to stdout
667
+ }
668
+ ```
669
+
670
+ This package's test suite runs both versions through a real `eslint` `Linter`, including
671
+ on a file whose function name and body contain multibyte text.
672
+
673
+ ## 10. Writing rules in TypeScript
674
+
675
+ `defineShellRule` and the types around it are exported from `@eslint-sh/eslint-plugin`, so a
676
+ TypeScript rule needs no `@eslint/core` import and no rule types of its own:
677
+
678
+ - `defineShellRule<Options, MessageIds>(…)` takes the options tuple and message ids as type
679
+ arguments, or infers the message ids from `meta.messages` when you leave them out. Either way,
680
+ `context.options` has your options' type, and `context.report` accepts only declared message ids.
681
+ - `context.sourceCode` is `ShellSourceCode`, so `getCommandSites()`, `getSyntax()` and the other
682
+ helpers are typed without a cast, and a listener keyed by a node type (`CallExpr`, `Word:exit`)
683
+ receives that node type, so its fields (`args`, `parts`, `cmd`, …) are typed; other selectors
684
+ receive `ShellNode`, which you narrow on `type`. See [Reading nodes](./reading-nodes.md).
685
+ - `ShellRuleContext<Options, MessageIds>` names the context for a helper outside `create()`, and
686
+ `ShellRule<Options, MessageIds>` names what `defineShellRule` returns. A `ShellRule` always
687
+ declares `meta.languages: ['sh/sh']`: an object written by hand without it is a type error.
688
+ - `ShellRuleSpec` names the object you pass to `defineShellRule`: the rule before the helper adds
689
+ `meta.languages`. (`ShellRule`, the finished rule, is what ESLint calls a rule definition.)
690
+ `ShellRuleMeta` and `ShellRuleVisitor` name its `meta` and the listener map, if you need to
691
+ spell them.
692
+
693
+ `docs/examples/no-listed-commands-rule.ts`:
694
+
695
+ ```ts
696
+ /**
697
+ * The TypeScript custom rule from the custom-rule tutorial
698
+ * (`../custom-rule-tutorial.md`, step 10). This package's test suite
699
+ * asserts the tutorial's code fence matches this file byte-for-byte,
700
+ * type-checks it against the package's published declarations, and runs it
701
+ * through a real `eslint` `Linter`.
702
+ *
703
+ * It reports any command named in its `commands` option, including through a
704
+ * wrapper such as `sudo`. Every type comes from `@eslint-sh/eslint-plugin`:
705
+ * the options and message ids passed to `defineShellRule` flow into
706
+ * `context.options` and `context.report`, and `ShellRuleContext` types the
707
+ * helper that reports outside `create()`.
708
+ *
709
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
710
+ */
711
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
712
+ import type { ShellCommandSite, ShellRuleContext } from '@eslint-sh/eslint-plugin';
713
+
714
+ /** The rule's one options object; `commands` defaults to `[]`. */
715
+ type Options = [{ commands?: string[] }?];
716
+
717
+ /** Reports `site` when the command it runs, after wrappers, is in `listed`. */
718
+ function reportIfListed(
719
+ context: ShellRuleContext<Options, 'listed'>,
720
+ site: ShellCommandSite,
721
+ listed: ReadonlySet<string>,
722
+ ): void {
723
+ const { effective } = site.resolveInvokedCommand();
724
+ if (effective.static && listed.has(effective.text)) {
725
+ context.report({ node: site.node, messageId: 'listed', data: { name: effective.text } });
726
+ }
727
+ }
728
+
729
+ const noListedCommands = defineShellRule<Options, 'listed'>({
730
+ meta: {
731
+ type: 'problem',
732
+ docs: {
733
+ description: 'disallow the commands named in the `commands` option',
734
+ },
735
+ schema: [
736
+ {
737
+ type: 'object',
738
+ properties: { commands: { type: 'array', items: { type: 'string' } } },
739
+ additionalProperties: false,
740
+ },
741
+ ],
742
+ messages: {
743
+ listed: '`{{name}}` is not allowed in this project.',
744
+ },
745
+ },
746
+ create(context) {
747
+ const listed = new Set(context.options[0]?.commands ?? []);
748
+ return {
749
+ File() {
750
+ for (const site of context.sourceCode.getCommandSites()) {
751
+ reportIfListed(context, site, listed);
752
+ }
753
+ },
754
+ };
755
+ },
756
+ });
757
+
758
+ export default noListedCommands;
759
+ ```
760
+
761
+ Enable it with its options, the same way as the rule in step 3:
762
+ `'my-rules/no-listed-commands': ['error', { commands: ['telnet'] }]`. For `sudo telnet example.com`
763
+ it reports `` `telnet` is not allowed in this project. ``, because `resolveInvokedCommand()` sees
764
+ through `sudo`.
765
+
766
+ ## 11. Test your rule
767
+
768
+ ESLint's `RuleTester` runs a rule against `valid` and `invalid` code, and it works with this plugin.
769
+ [`docs/examples/no-backticks-rule-tester.mjs`](./examples/no-backticks-rule-tester.mjs) tests the
770
+ step 8 rule, including its fix, and a second rule that only offers a suggestion:
771
+
772
+ ```mjs
773
+ /**
774
+ * The "test your rule" example from the custom-rule tutorial
775
+ * (`../custom-rule-tutorial.md`, step 11). It tests the step 8 rule
776
+ * (`./no-backticks-rule.mjs`) with ESLint's own `RuleTester`, and a small
777
+ * inline rule that offers a suggestion rather than an automatic fix.
778
+ *
779
+ * `RuleTester` needs two settings to run shell rules: the plugin registered
780
+ * under `plugins`, and `language: 'sh/sh'`. Leaving either out
781
+ * makes `RuleTester` throw a configuration error before it checks any case;
782
+ * the tutorial quotes both messages.
783
+ *
784
+ * Run it with `node no-backticks-rule-tester.mjs`. `RuleTester` uses
785
+ * `describe`/`it` when the test runner defines them, and otherwise throws on
786
+ * the first failing case, so a quiet exit means every case passed.
787
+ *
788
+ * This file and `./no-backticks-rule.mjs` ship in the `@eslint-sh/eslint-plugin`
789
+ * package only (`docs/examples/`); the umbrella `eslint-sh` package does not
790
+ * include them. To import from the umbrella instead, change the plugin import
791
+ * below and the one in `./no-backticks-rule.mjs` to `eslint-sh`.
792
+ *
793
+ * @see ../custom-rule-tutorial.md — the tutorial this example backs
794
+ * @see https://eslint.org/docs/latest/integrate/nodejs-api#ruletester
795
+ */
796
+ import { RuleTester } from 'eslint';
797
+ import sh, { defineShellRule } from '@eslint-sh/eslint-plugin';
798
+ import noBackticks from './no-backticks-rule.mjs';
799
+
800
+ const tester = new RuleTester({
801
+ plugins: { sh },
802
+ language: 'sh/sh',
803
+ });
804
+
805
+ tester.run('no-backticks', noBackticks, {
806
+ valid: [
807
+ // An empty file, and a command substitution that already uses `$(…)`.
808
+ { code: '' },
809
+ { code: 'now=$(date +%s)\n' },
810
+ ],
811
+ invalid: [
812
+ {
813
+ code: 'now=`date +%s`\n',
814
+ // `messageId` checks which message fired; `line` and `column` check where.
815
+ errors: [{ messageId: 'backticks', line: 1, column: 5 }],
816
+ // `output` is the file after ESLint applies the fix.
817
+ output: 'now=$(date +%s)\n',
818
+ },
819
+ {
820
+ // Multibyte text before and inside the substitutions.
821
+ code: 'echo "héllo `id -u` 🎉" `date`\n',
822
+ errors: [{ messageId: 'backticks' }, { messageId: 'backticks' }],
823
+ output: 'echo "héllo $(id -u) 🎉" $(date)\n',
824
+ },
825
+ ],
826
+ });
827
+
828
+ /**
829
+ * A rule that only suggests: it reports `which` and offers `command -v`, which
830
+ * the reader chooses to apply. A rule with suggestions declares
831
+ * `hasSuggestions: true`.
832
+ */
833
+ const noWhich = defineShellRule({
834
+ meta: {
835
+ type: 'suggestion',
836
+ hasSuggestions: true,
837
+ docs: { description: 'prefer `command -v` over `which`' },
838
+ schema: [],
839
+ messages: {
840
+ noWhich: '`which` is not portable.',
841
+ useCommandV: 'Use `command -v` instead.',
842
+ },
843
+ },
844
+ create(context) {
845
+ return {
846
+ CallExpr(node) {
847
+ const [name] = node.args ?? [];
848
+ if (name === undefined || context.sourceCode.getText(name) !== 'which') {
849
+ return;
850
+ }
851
+ context.report({
852
+ node: name,
853
+ messageId: 'noWhich',
854
+ suggest: [
855
+ { messageId: 'useCommandV', fix: (fixer) => fixer.replaceText(name, 'command -v') },
856
+ ],
857
+ });
858
+ },
859
+ };
860
+ },
861
+ });
862
+
863
+ tester.run('no-which', noWhich, {
864
+ valid: [{ code: 'command -v ls\n' }],
865
+ invalid: [
866
+ {
867
+ code: 'which ls\n',
868
+ // `output: null` asserts that the rule has no automatic fix.
869
+ output: null,
870
+ errors: [
871
+ {
872
+ messageId: 'noWhich',
873
+ suggestions: [{ messageId: 'useCommandV', output: 'command -v ls\n' }],
874
+ },
875
+ ],
876
+ },
877
+ ],
878
+ });
879
+
880
+ ```
881
+
882
+ Run it with `node no-backticks-rule-tester.mjs` from a folder that has `eslint`, the plugin and the
883
+ example files installed or copied in. `RuleTester` uses `describe` and `it` when your test runner
884
+ defines them (Vitest with `globals: true`, Mocha, Jest), and otherwise runs each case in turn and
885
+ throws on the first failure, so a quiet exit means every case passed. Each `tester.run(name, rule,
886
+ cases)` takes:
887
+
888
+ - `valid`: code the rule must not report.
889
+ - `invalid`: code the rule must report. `errors` lists each report by `messageId`, and may add
890
+ `line`, `column` and the other location fields. `output` is the file after ESLint applies the
891
+ rule's fix, and is how you test a fixer; for a rule with no fix, set `output: null`.
892
+ - `suggestions` inside an error lists the suggestions that report offers, each with its `messageId`
893
+ and the `output` that applying it produces. A rule with suggestions must declare
894
+ `meta.hasSuggestions: true`.
895
+
896
+ Put multibyte text (`é`, `🎉`) in at least one case of a rule that edits text, as the example does:
897
+ ESLint measures fix ranges in string offsets, and a rule that confuses them with bytes passes on
898
+ ASCII and breaks on anything else.
899
+
900
+ ### The two options to get right
901
+
902
+ `RuleTester` takes the same options as a flat-config entry, and a shell rule needs both of these:
903
+
904
+ ```js
905
+ const tester = new RuleTester({
906
+ plugins: { sh }, // register the plugin, so `sh/sh` resolves
907
+ language: 'sh/sh', // parse each case as shell, not JavaScript
908
+ });
909
+ ```
910
+
911
+ Leaving out `language` is the easier mistake: `RuleTester` falls back to JavaScript, and the first
912
+ case fails with
913
+
914
+ ```text
915
+ TypeError: ESLint configuration in rule-tester is invalid: Key "rules": The following rules do not support the language "js/js":
916
+ - "rule-to-test/no-backticks"
917
+ ```
918
+
919
+ Leaving out `plugins: { sh }` fails with
920
+
921
+ ```text
922
+ TypeError: ESLint configuration in rule-tester is invalid: Key "language": Could not find "sh" in plugin "sh".
923
+ ```
924
+
925
+ Both appear when the test file runs, before any case is checked. A rule built with `defineShellRule`
926
+ declares `meta.languages: ['sh/sh']`, so ESLint refuses to run it under any other language.