@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,440 @@
1
+ # Linting code you don't control
2
+
3
+ eslint-sh is often a policy gate: CI lints a pull request, an agent's output, or a vendored
4
+ repository whose author does not own the policy. In that setting, the author controls the scripts
5
+ under review and every other file in the checkout. This page covers what that lets them change, how
6
+ to run the lint from a directory outside those files, so that they can't change its configuration
7
+ or its exit code, and what a passing run still doesn't tell you:
8
+
9
+ - [What a script's comments can switch off](#what-a-scripts-comments-can-switch-off)
10
+ - [A policy config that ignores them](#a-policy-config-that-ignores-them)
11
+ - [Run ESLint from outside the checkout](#run-eslint-from-outside-the-checkout)
12
+ - [Declaration conventions that read files](#declaration-conventions-that-read-files)
13
+ - [Which files get linted](#which-files-get-linted)
14
+ - [What this does not cover](#what-this-does-not-cover)
15
+
16
+ Everything below was run against ESLint 10.2.0, the oldest version this plugin's peer range allows,
17
+ and against 10.7.0.
18
+
19
+ ## What a script's comments can switch off
20
+
21
+ ESLint's inline comments work in shell scripts the same way they do in JavaScript, with `#` as the
22
+ comment marker. With the default `linterOptions`, a script can silence any rule for itself, and
23
+ neither `sh/recommended` nor `sh/agent-authored` changes that. Each of these scripts lints with no
24
+ `sh/no-eval` report under `sh.configs.recommended`:
25
+
26
+ ```sh
27
+ #!/bin/bash
28
+ # eslint-disable-next-line sh/no-eval
29
+ eval "$x"
30
+ ```
31
+
32
+ | Comment | Effect |
33
+ | -------------------------------------------- | -------------------------------------------- |
34
+ | `# eslint-disable-next-line sh/no-eval` | Silences the rule on the next line. |
35
+ | `eval "$x" # eslint-disable-line sh/no-eval` | Silences the rule on its own line. |
36
+ | `# eslint-disable` … `# eslint-enable` | Silences every rule between the two. |
37
+ | `# eslint sh/no-eval: off` | Turns the rule off for the whole file. |
38
+
39
+ The `eslint` prefix is the default `languageOptions.directivePrefix`. A config that sets it, for
40
+ example to `shellcheck-sh`, makes `# shellcheck-sh-disable-next-line sh/no-eval` the working form
41
+ and turns `# eslint-disable-next-line` into an ordinary comment. The prefix is chosen by the config,
42
+ not by the script, and it does not stop directives, it only renames them.
43
+
44
+ ## A policy config that ignores them
45
+
46
+ Set `linterOptions.noInlineConfig` in the config you lint with. The `globalIgnores` line and the
47
+ `checkout/` in `files` belong to [the recipe](#run-eslint-from-outside-the-checkout), which explains
48
+ them.
49
+
50
+ ```js
51
+ // policy/eslint.config.mjs
52
+ import { defineConfig, globalIgnores } from 'eslint/config';
53
+ import sh from '@eslint-sh/eslint-plugin';
54
+
55
+ export default defineConfig([
56
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
57
+ {
58
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
59
+ extends: [sh.configs.recommended],
60
+ linterOptions: { noInlineConfig: true },
61
+ },
62
+ ]);
63
+ ```
64
+
65
+ `noInlineConfig: true` makes ESLint ignore every comment in the table above, whatever the
66
+ `directivePrefix`. The rule reports as if the comment were not there, and ESLint adds a warning with
67
+ no rule id at the comment itself. For the script above, with its other report:
68
+
69
+ ```text
70
+ 1:1 error Enable strict mode with `set -euo pipefail` within the first 5 statements; not enabled: errexit, nounset, pipefail sh/require-strict-mode
71
+ 2:1 warning '# eslint-disable-next-line sh/no-eval' has no effect because you have 'noInlineConfig' setting in your config
72
+ 3:1 error `eval` runs text as shell code at run time, so the code it executes cannot be statically reviewed. Write the commands directly instead sh/no-eval
73
+ ```
74
+
75
+ The plugin returns its directives to ESLint through the language's `getDisableDirectives` and
76
+ `getInlineConfigNodes` hooks, and ESLint applies `noInlineConfig` to them as it does to
77
+ JavaScript comments. Under `--max-warnings 0`, that warning fails the
78
+ run on its own: a contributed script that contains any directive fails the gate, even one that would
79
+ have suppressed nothing. If you would rather let those through, run without `--max-warnings 0` and
80
+ read the warnings, but then no other warning fails the run either (see the
81
+ [continuous integration exit codes](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration)).
82
+
83
+ **If you accept directives instead.** `linterOptions.reportUnusedDisableDirectives` sets the
84
+ severity of a disable comment that suppressed nothing. ESLint 10's default is `"warn"`; `"error"`
85
+ fails the run on one:
86
+
87
+ ```js
88
+ // policy/eslint.config.mjs, directives allowed
89
+ import { defineConfig, globalIgnores } from 'eslint/config';
90
+ import sh from '@eslint-sh/eslint-plugin';
91
+
92
+ export default defineConfig([
93
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
94
+ {
95
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
96
+ extends: [sh.configs.recommended],
97
+ linterOptions: { reportUnusedDisableDirectives: 'error' },
98
+ },
99
+ ]);
100
+ ```
101
+
102
+ This keeps stale directives out of the tree, but it is not a gate: the `# eslint-disable-next-line
103
+ sh/no-eval` above still silences `sh/no-eval`, because it did suppress something. Setting both
104
+ options is allowed, but `reportUnusedDisableDirectives` then reports nothing, because
105
+ `noInlineConfig` has already discarded every directive.
106
+
107
+ ## Run ESLint from outside the checkout
108
+
109
+ `linterOptions` only helps if the contributor can't replace the config, ESLint or the plugin, and
110
+ can't change the result some other way. ESLint finds several of its inputs in the directory it runs
111
+ in, and Node looks through that directory's parents for packages. When that directory is the
112
+ checkout, every one of these belongs to the contributor:
113
+
114
+ - **The config.** `eslint .` without `--config` imports the `eslint.config.mjs` in the directory it
115
+ runs in, as JavaScript, with your job's environment variables. ESLint 10 also loads an
116
+ `eslint.config.*` from a subdirectory for the files under it, so a nested config in the checkout
117
+ runs, and replaces your policy for its directory, even if your config sits at the root.
118
+ - **Where the config's imports resolve.** `import sh from '@eslint-sh/eslint-plugin'` resolves
119
+ from the config file's own location. A copy of your config placed inside the checkout imports
120
+ the checkout's `node_modules`.
121
+ - **Which ESLint runs.** `npx eslint` in the checkout runs the checkout's
122
+ `node_modules/.bin/eslint`.
123
+ - **The formatter.** ESLint resolves a formatter name, including the default `stylish`, as the
124
+ package `eslint-formatter-stylish` from the directory it runs in and then from each of its parents,
125
+ with or without `--config`. That package is code in your job. It can print what it likes and set
126
+ the exit code.
127
+ - **The suppressions file.** ESLint applies `eslint-suppressions.json` from the directory it runs in
128
+ whenever that file exists, with no flag, and neither `--config` nor `noInlineConfig` affects it.
129
+ An error listed in the file is hidden, and the run exits 0.
130
+ - **The cache file.** With `--cache`, ESLint reads `.eslintcache` from the directory it runs in and
131
+ reuses the results recorded there. The default `--cache-strategy metadata` trusts a recorded size
132
+ and modification time. `--cache-strategy content` doesn't help either, because the hash it
133
+ compares against is in the same file.
134
+ - **Your job's own relative paths.** A relative entry in `PATH`, or a relative file in
135
+ `NODE_OPTIONS`, resolves against the directory ESLint runs in, so either can run a file from the
136
+ checkout. A version manager may read its settings from there too: `nodenv` runs ESLint under the
137
+ Node version named in a `.node-version` in the checkout or any parent. Other version managers were
138
+ not tested.
139
+
140
+ Installing the checkout is a separate hazard: `npm ci` and `npm install` run the package's lifecycle
141
+ scripts, such as `preinstall`. The lint needs none of the checkout's dependencies, so don't install
142
+ them.
143
+
144
+ The recipe is to run ESLint somewhere else and pass the checkout as an argument. Keep the policy
145
+ where the contributor can't change it in the same change, such as a separate repository or a path
146
+ your CI provides, with `eslint` and `@eslint-sh/eslint-plugin` installed there from its own lockfile.
147
+ If `policy` is a link to such a path, the rule below applies to the parents of its real location
148
+ too, and the guards don't look there.
149
+ Put it beside the checkout, in a directory your job creates and the contributor can't write to.
150
+ Neither that directory nor any of its parents may be inside the contributor's files: a CI job often
151
+ starts in a clone of the repository under review, so don't create it there. The recipe refuses a
152
+ `node_modules` in a parent, but it has no guard for the rest of that rule, such as a `.node-version`
153
+ there.
154
+
155
+ ```text
156
+ job-directory/
157
+ policy/ eslint.config.mjs and its own node_modules
158
+ checkout/ the code under review
159
+ ```
160
+
161
+ Create `checkout` yourself, so that nothing of the contributor's lands beside it. From a repository:
162
+
163
+ ```sh
164
+ git clone URL checkout
165
+ ```
166
+
167
+ From a tarball, keep the input archive outside `job-directory` (here, `../x.tar`) and unpack only
168
+ into `checkout`. Keep every path as archived: the policy covers files directly inside any `bin/`
169
+ directory under `checkout`, so wrappers and siblings need no special handling.
170
+
171
+ ```sh
172
+ [ ! -e checkout ] && [ ! -L checkout ] || { printf 'checkout already exists\n' >&2; exit 1; }
173
+ mkdir checkout && tar -xf ../x.tar -C checkout || exit 1
174
+ ```
175
+
176
+ Stop the job if this fails: a failed `tar` can leave only some of the files. Don't strip path
177
+ components or move files out of a wrapper; stripping can silently discard top-level files.
178
+
179
+ Then, from `job-directory`, not from inside the checkout:
180
+
181
+ ```sh
182
+ [ "$(ls -A | wc -l)" -eq 2 ] && [ -d policy ] && [ -d checkout ] || {
183
+ printf 'the job directory must hold only checkout and policy\n' >&2
184
+ exit 1
185
+ }
186
+ dir=$(pwd -P)
187
+ while :; do
188
+ if [ -e "$dir/node_modules" ]; then
189
+ printf 'node_modules in or above the job directory: %s\n' "$dir" >&2
190
+ exit 1
191
+ fi
192
+ [ "$dir" != / ] || break
193
+ dir=$(dirname "$dir")
194
+ done
195
+ policy/node_modules/.bin/eslint \
196
+ --config policy/eslint.config.mjs \
197
+ --max-warnings 0 \
198
+ checkout
199
+ ```
200
+
201
+ - The working directory is what closes the list above. `eslint-suppressions.json`, `.eslintcache`
202
+ and any relative path in your job's environment resolve in `job-directory`, the formatter name
203
+ resolves there and in its parents, and the checkout's copies are never consulted.
204
+ - The first guard refuses anything else in `job-directory`, because ESLint reads its inputs from
205
+ there: a tarball unpacked here rather than as above can leave an `eslint-suppressions.json` beside
206
+ the checkout. It can't catch such a tarball writing into `policy/`, which only unpacking elsewhere
207
+ prevents.
208
+ - The second guard refuses a `node_modules` in `job-directory` or any parent, because Node looks
209
+ there for the formatter and for a package ESLint's own dependencies ask for on every run, whatever
210
+ `--format` says, and a package found there runs in your job and can set the exit code. If it
211
+ refuses a `node_modules` of your own, such as one in your home directory, make the job directory
212
+ with `mktemp -d` instead.
213
+ - `--config` loads only the policy, and its imports resolve from `policy/node_modules`.
214
+ - The checkout has to be under the working directory. With `--config`, ESLint matches the config's
215
+ `files` and `ignores` against the directory it runs in and treats anything outside it as ignored:
216
+ running from inside `policy/` with `../checkout`, or from elsewhere with an absolute path, exits 2
217
+ with `all of the files matching the glob pattern ... are ignored`. The policy's
218
+ `checkout/**/bin/?*` matches files directly in a `bin` at any depth under `checkout`.
219
+ - `!**/node_modules/` in `globalIgnores` turns off ESLint's default ignore of `**/node_modules/`.
220
+ Without it, a script under any directory named `node_modules`, at any depth, is not linted, and
221
+ the contributor can commit a directory of that name anywhere. A wrapper named `node_modules`
222
+ or starting with a dot is covered too. `node_modules/.bin` is not a directory named `bin`:
223
+ regular files there need a `.sh` or `.bash` extension to match; its usual symlinks fail the check.
224
+ - `checkout/.git/` in `globalIgnores` keeps the lint out of the clone's repository data, because
225
+ ESLint's default ignores only a `.git` in the directory it runs in, and a branch called
226
+ `fix/deploy.sh` is a file there that matches `files`.
227
+ - `--max-warnings 0` makes warnings, including the "has no effect" warnings above, fail the run.
228
+ The exit codes are the ones in the
229
+ [continuous integration table](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration).
230
+ - Leave `--cache` off. The recipe needs no cache, and if you add one, it must not come from the
231
+ checkout or from anything else the contributor could write.
232
+
233
+ To find what was left, this command was run with every `fs` call, module load and child process
234
+ logged, against a checkout holding each ESLint input named on this page and a marker-writing copy
235
+ of every package in the policy's install. Under the checkout, ESLint only listed directories and read the
236
+ files it lints. It loaded no module from there and started no process. That is a result for the
237
+ ESLint versions above, not a promise from ESLint; this page's tests repeat the trace on the version
238
+ the repository uses.
239
+
240
+ The workflow that runs this command is part of the gate too: keep it, like the policy, where the
241
+ contributor's change can't edit it. Nothing of the contributor's should have run on the machine
242
+ before it, either: an install, build or test run in the same job can change any file the job can
243
+ write, including the policy.
244
+
245
+ ## Declaration conventions that read files
246
+
247
+ A [`DeclarationConvention`](./declaration-conventions.md) is code in your config, and one that reads
248
+ files reads whatever path it is given. When linting code you don't control, read only paths you
249
+ control: a file built from the policy config's own location (`new URL('./commands.txt',
250
+ import.meta.url)`), or data in the config itself. Don't read a file located from the linted script's
251
+ path, such as the per-script sidecars in
252
+ [declarations outside the script](./declaration-conventions.md#declarations-outside-the-script) or
253
+ `scriptSidecarCommands()` in the repository's
254
+ [container-entrypoint example pack](https://github.com/mike-north/eslint-sh/tree/main/examples/container-entrypoint-pack):
255
+ in a checkout under review, every file next to a script belongs to the contributor.
256
+
257
+ ## Which files get linted
258
+
259
+ ESLint lints the files the policy's `files` patterns match, and the contributor chooses file names.
260
+ With the policy above, a checkout with a clean `ok.sh` and these scripts, each starting `#!/bin/bash`
261
+ and containing `eval "$x"`, exits 0 with no report from the recipe:
262
+
263
+ - `ci/run` and `deploy`: no extension, outside any `bin/`.
264
+ - `build.SH`: the patterns are case-sensitive.
265
+ - `x.zsh`: the extension is not in the patterns.
266
+ - `script.sh.txt`: the pattern needs the name to end in `.sh`.
267
+
268
+ Naming a script differently is enough to avoid being linted. Widening the patterns does not close
269
+ this, because any list of names is a list the contributor can step around. What you can do is
270
+ check the checkout for files that start with a shell shebang and that the patterns do not match,
271
+ and fail the job when there are any. Run this from `job-directory`, before the lint, from a workflow
272
+ the contributor can't edit:
273
+
274
+ ```sh
275
+ [ -d checkout ] && [ ! -L checkout ] || { printf 'no checkout directory here\n' >&2; exit 2; }
276
+ status=0
277
+ while IFS= read -r -d '' file; do
278
+ if [ -L "$file" ] || [ ! -f "$file" ] || [ ! -r "$file" ]; then
279
+ printf 'symlink, special or unreadable file: %q\n' "$file" >&2
280
+ status=1
281
+ continue
282
+ fi
283
+ case "$file" in
284
+ *.sh | *.bash) continue ;;
285
+ esac
286
+ # The policy covers direct children of every bin, regardless of wrappers.
287
+ case "${file%/*}" in
288
+ */bin) continue ;;
289
+ esac
290
+ if head -c 1024 "$file" | head -n 1 | LC_ALL=C grep -Eiq '^#!.*sh'; then
291
+ printf 'not linted, but has a shell shebang: %q\n' "$file" >&2
292
+ status=1
293
+ fi
294
+ done < <(find checkout -path checkout/.git -type d -prune -o ! -type d -print0)
295
+ [ "$status" -eq 0 ] || exit "$status"
296
+ ```
297
+
298
+ This needs `bash` (it uses `read -d` and process substitution). It exits non-zero when it finds
299
+ something and otherwise falls through, so it can sit above the recipe in one script or run as its
300
+ own step. Its first line exits 2 without a real `checkout` directory, because a failing `find`
301
+ wouldn't fail the check, and a check run from the wrong directory would pass every time. The first
302
+ `case` covers the extensions; the second checks the parent directory, so it covers direct children
303
+ of every `bin/` without accidentally accepting `bin/sub/run`. Keep these checks in sync with the
304
+ policy's `files`.
305
+
306
+ To rename `checkout`, change it everywhere. Left behind in the guards or the recipe's argument, the
307
+ old name stops the run, and in `globalIgnores` it can only add failures. The policy's `files`, the
308
+ `case` and the `find` must change together: if only `files` is left behind, `.sh` files are still
309
+ linted, so nothing looks wrong, but `bin/` silently isn't.
310
+
311
+ The check looks everywhere in the checkout except a top-level `.git` directory, including under
312
+ `node_modules`. The lint skips that directory too, and only a tarball can put files of its own there,
313
+ so don't run anything from it. The check names every file in the example above except `ok.sh`, and it
314
+ fails on any symlink, special file or file it can't read (see [symlinks](#what-this-does-not-cover)),
315
+ so an installed `node_modules`, whose `.bin` holds symlinks, fails it too. A directory it can't read
316
+ only makes `find` print an error, and the recipe then exits 2 on it. It flags any first line starting `#!` that contains `sh` in any
317
+ case, so `#!/usr/local/bin/publish` fails it as well. When it fails, either fix the name or extend
318
+ the policy's `files` to cover the file.
319
+
320
+ It uses `find` where `git ls-files` would be shorter, because git reads the checkout's own repository
321
+ configuration, which a checkout you did not clone yourself can set, and that configuration can run
322
+ commands. Don't run git in such a checkout, including to list changed files.
323
+
324
+ It is not a complete answer. A script with no shebang, outside every `bin/` and without a `.sh` or
325
+ `.bash` extension, passes both the lint and this check, even if something runs it as `sh deploy` or
326
+ `source`s it. So does one whose
327
+ shebang doesn't contain `sh`, such as a wrapper with another name. The check narrows what a
328
+ contributor can hide; it does not make the set of linted files equal to the set of scripts the
329
+ project runs.
330
+
331
+ The lint sees the files on disk, not the commit, so run it on a file system that keeps every name
332
+ apart, such as Linux's: on macOS's default, a commit with both `A.sh` and `a.sh` clones to one file,
333
+ and only that file is linted. The same goes for names that differ only in Unicode normal form,
334
+ which collide on APFS even on a case-sensitive volume. Lint what you will run, too. A plain clone leaves a submodule's directory empty, so use
335
+ `git clone --recurse-submodules` if anything later fetches submodules. And the contributor's
336
+ `.gitattributes` can mark a file `export-ignore`, which keeps it out of a `git archive` tarball, so
337
+ a tarball can lint clean while a clone of the same commit holds the file.
338
+
339
+ An honest repository can also fail the recipe. ESLint lints `*.js`, `*.mjs` and `*.cjs` by default,
340
+ whatever the policy's `files` say, and `checkout/**/bin/?*` covers everything directly in any `bin/`
341
+ under `checkout`, including vendored binaries. Those files are linted as shell, and can fail with
342
+ parse errors or shell policy findings. A JavaScript file with a disable comment, a JavaScript file
343
+ that ESLint's default parser rejects, or a
344
+ Python script in `bin/` each exits 1. So does any JavaScript file directly in `bin/`, however valid,
345
+ with a message telling you to add a `files` entry that is already there, and an empty `bin/.gitkeep`.
346
+ A checkout with nothing to lint exits 2 with `all of the files matching the glob pattern ... are
347
+ ignored`. These failures are closed rather than open. The policy's `files` and `ignores` are yours
348
+ to adjust, as long as the check's `case` patterns stay the same as what you leave linted.
349
+
350
+ ## What this does not cover
351
+
352
+ Run this way, ESLint reads the checkout's files only as text to lint. That is all the recipe does.
353
+
354
+ **A clean lint is not evidence that a script is harmless.** Exit 0 means your policy's rules found
355
+ nothing in the files your policy matched. The rules look for patterns; they do not work out what a
356
+ script does. Under `sh.configs.recommended`, `eval "$1"` is an error and `bash -c "$1"`, which also
357
+ runs its argument as code, exits 0 with no report. Passing text to a shell another way, or
358
+ downloading a file and running it, passes too. Don't take a clean lint as a reason to run a script
359
+ you haven't reviewed.
360
+
361
+ **It is not a sandbox.** Linting parses the contributor's files in the job's own process, with the
362
+ job's environment variables and file access. The linter does not execute the scripts under review:
363
+ a script that touches a file at its top level, in `$(...)`, in `<(...)`, in an `eval` string, or
364
+ in a `source`d file created none of the test's marker files. But that is not isolation. If a leaked
365
+ secret or a parser fault in the job is the concern, run the lint in a container or a CI job that has
366
+ no secrets and no network access it does not need. Give the job a time limit: a named pipe called
367
+ `f.sh`, which a tarball can deliver though git can't, hung the run until it was killed. This page
368
+ did not test pathological inputs exhaustively.
369
+
370
+ **The report contains the contributor's text.** ESLint prints file names as they are, and rule
371
+ messages quote parts of the script. A file name containing newlines can print lines that look like
372
+ a clean summary. The exit code doesn't depend on any of that text, so gate on the exit code, and
373
+ treat the report as untrusted wherever you display it.
374
+
375
+ **`noInlineConfig` does not cover a script's own declarations.** `noInlineConfig` ignores ESLint's
376
+ comments. It leaves alone anything a `DeclarationConvention` reads out of the script. With
377
+ `commentPragma` bound, `sh/no-undeclared-commands` at `error`, and `noInlineConfig: true`, this
378
+ script has two reports, for `curl` and for `sh`:
379
+
380
+ ```sh
381
+ #!/bin/bash
382
+ set -euo pipefail
383
+ curl https://example.com/install | sh
384
+ ```
385
+
386
+ and this one, whose header declares them itself, has none and the run exits 0:
387
+
388
+ ```sh
389
+ #!/bin/bash
390
+ # uses: curl sh
391
+ set -euo pipefail
392
+ curl https://example.com/install | sh
393
+ ```
394
+
395
+ Don't bind `commentPragma`, or any convention that reads declarations from the script, in a policy
396
+ for code you don't control. To use the declaration rules as a gate, take the declarations from the
397
+ policy: a convention that returns a list written in the config, which the script cannot add to.
398
+
399
+ ```js
400
+ // policy/eslint.config.mjs
401
+ const allowed = ['jq', 'git'];
402
+
403
+ /** Declarations come from this config, never from the script. */
404
+ const policyDeclarations = {
405
+ declaredCommands: () =>
406
+ allowed.map((name) => ({
407
+ kind: 'described',
408
+ name,
409
+ description: 'the allowed commands in policy/eslint.config.mjs',
410
+ })),
411
+ };
412
+
413
+ // In the config entry: settings: { sh: { declarationConvention: policyDeclarations } },
414
+ ```
415
+
416
+ With that convention, the script with the `# uses: curl sh` header is reported for `curl` and `sh`,
417
+ the same as the one without it.
418
+
419
+ **A relative sidecar path resolves against the working directory.** With the recipe, that is
420
+ `job-directory`. With the example pack's `sidecarCommands('commands.txt')` in the policy and no such
421
+ file in `job-directory`, the config fails to load and ESLint exits 2, whatever the checkout holds.
422
+ A path that reaches into the checkout hands over the list: with
423
+ `sidecarCommands('checkout/commands.txt')` and that file listing `curl` and `sh`, the script above
424
+ exits 0. The same config with the absolute path of a file in your policy,
425
+ `sidecarCommands('/ci/policy/commands.txt')`, reports both commands. Give a convention an absolute
426
+ path to a file outside the checkout, or build it from `import.meta.url` as
427
+ [above](#declaration-conventions-that-read-files), or use no sidecar.
428
+
429
+ **A symlink in the checkout can point outside it.** A file symlink whose name matches the policy's
430
+ `files`, or ESLint's default JavaScript patterns, is followed: ESLint lints the file it points at,
431
+ wherever that is. A `leak.sh` linking to a
432
+ text file outside the checkout printed that file's parse error, such as ``Parsing error: `if` must
433
+ be followed by a statement list``, and a link to a script outside the checkout printed rule
434
+ messages naming its unquoted variables and their line numbers. If the report is posted where the
435
+ contributor can read it, that leaks a little of whatever the link points to, and text inside an
436
+ unquoted `$(...)` prints verbatim. Other symlinked directories are not followed: files behind a link
437
+ to a directory were not linted or reported. A link to a directory or to nothing stops the run with
438
+ exit 2 if its own name matches `files` or ESLint's default JavaScript patterns, and is skipped
439
+ otherwise. The check above rejects every symlink under the checkout, including one named
440
+ `node_modules` or `.git`, which closes the file case when it runs before the lint.
@@ -0,0 +1,206 @@
1
+ # Wrapper rules
2
+
3
+ Many projects require some commands to run through a wrapper: network calls through a retry
4
+ helper, infrastructure changes through an audit logger, deploys through a lock. These are
5
+ "command X must be run through wrapper Y" policies. `createWrapperRule` builds a rule that
6
+ enforces one, and `sh/require-network-wrapper` is the built-in rule made with it.
7
+
8
+ Which commands need a wrapper, and which wrapper, is your project's policy. This plugin ships no
9
+ wrapper name. You bind both, either in a rule pack with `createWrapperRule` or in your config
10
+ with [`sh/require-network-wrapper`](./rules/require-network-wrapper.md)'s `wrapper` option.
11
+
12
+ ## `createWrapperRule`
13
+
14
+ ```ts
15
+ import type { RuleDefinition } from '@eslint/core';
16
+ import type { WrapperSpec } from '@eslint-sh/eslint-plugin';
17
+
18
+ interface WrapperRuleConfig {
19
+ /** Command names that must be run through the wrapper. */
20
+ readonly commands: readonly string[];
21
+ /** The wrapper: a name, or a WrapperSpec that declares its options. */
22
+ readonly wrapper: string | WrapperSpec;
23
+ /** Listed commands that count only with some subcommands, keyed by command name. */
24
+ readonly subcommands?: Readonly<Record<string, WrapperRuleSubcommands>>;
25
+ /** Invocations that only act locally and are not reported, keyed by the command as the message names it. */
26
+ readonly localInvocations?: Readonly<Record<string, WrapperRuleLocalInvocation>>;
27
+ /** Overrides the rule's meta.docs.url and meta.docs.description. */
28
+ readonly docs?: { readonly url?: string; readonly description?: string };
29
+ }
30
+
31
+ /** The command's own options before the subcommand use WrapperSpec's flag fields. */
32
+ interface WrapperRuleSubcommands
33
+ extends Pick<WrapperSpec, 'noArgFlags' | 'argFlags' | 'stopsChainFlags'> {
34
+ /** The subcommands that make an invocation count. */
35
+ readonly names: readonly string[];
36
+ }
37
+
38
+ interface WrapperRuleLocalInvocation {
39
+ /** Complete argument lists (after the command and its subcommand) that are local. */
40
+ readonly argLists?: readonly (readonly string[])[];
41
+ /** Local when the first operand is a static word starting with one of `localPrefixes`. */
42
+ readonly localOperand?: Pick<WrapperSpec, 'noArgFlags' | 'argFlags'> & {
43
+ readonly localPrefixes: readonly string[];
44
+ };
45
+ }
46
+
47
+ function createWrapperRule(config: WrapperRuleConfig): RuleDefinition;
48
+ ```
49
+
50
+ The returned rule:
51
+
52
+ - declares `meta.languages: ["sh/sh"]`, so ESLint applies it only to files linted with this
53
+ plugin's shell language;
54
+ - takes no options, because the config is bound when the rule is made;
55
+ - reports message id `unwrappedCommand` on each command that should be wrapped and is not, naming
56
+ the command and the wrapper: `` `kubectl` must be run through `audit-run`. ``
57
+ - has a `meta.docs.url` pointing at this page and a `meta.docs.description` naming the commands
58
+ and the wrapper, unless `docs` overrides them. Set `docs.url` to your pack's own documentation,
59
+ so the link in a lint report leads your users to your policy.
60
+
61
+ `createWrapperRule` throws a `TypeError` when the config is malformed: no `commands` array, an
62
+ empty wrapper name, a `WrapperSpec` with no `names`, a subcommand entry with no `names`, or a
63
+ `docs.url` or `docs.description` that is not a non-empty string, or a malformed `localInvocations`
64
+ entry.
65
+
66
+ ## Example: a rule pack
67
+
68
+ This pack requires `kubectl` and `terraform apply`/`terraform destroy` to be run through its
69
+ `audit-run` wrapper. `audit-run` takes a `--ticket <id>` option, and terraform's `-no-color` may
70
+ come before its subcommand. This file ships with the package as
71
+ `docs/examples/audit-wrapper-pack.mjs`, and CI runs it.
72
+
73
+ ```mjs
74
+ /**
75
+ * A rule pack that requires infrastructure CLIs to be run through its
76
+ * `audit-run` wrapper. From `../wrapper-rules.md`, and run in CI.
77
+ */
78
+ import { createWrapperRule } from '@eslint-sh/eslint-plugin';
79
+
80
+ const requireAudit = createWrapperRule({
81
+ commands: ['kubectl', 'terraform'],
82
+ wrapper: { names: ['audit-run'], argFlags: ['--ticket'] },
83
+ subcommands: {
84
+ terraform: { names: ['apply', 'destroy'], noArgFlags: ['-no-color'] },
85
+ },
86
+ });
87
+
88
+ export default { rules: { 'require-audit': requireAudit } };
89
+ ```
90
+
91
+ Register the pack beside this plugin and turn the rule on:
92
+
93
+ ```js
94
+ // eslint.config.js
95
+ import sh from '@eslint-sh/eslint-plugin';
96
+ import acme from './audit-wrapper-pack.mjs';
97
+
98
+ export default [
99
+ {
100
+ files: ['**/*.sh'],
101
+ plugins: { sh, acme },
102
+ language: 'sh/sh',
103
+ rules: { 'acme/require-audit': 'error' },
104
+ },
105
+ ];
106
+ ```
107
+
108
+ ```sh
109
+ kubectl apply -f app.yaml # reported: kubectl is not wrapped
110
+ terraform -no-color apply # reported as `terraform apply`
111
+ audit-run --ticket OPS-1 kubectl apply -f a.yaml # clean
112
+ terraform plan # clean: plan is not a listed subcommand
113
+ ```
114
+
115
+ ## What counts as wrapped
116
+
117
+ Every command in the file is checked: top level, in functions, in pipelines, in conditionals,
118
+ and inside command substitutions (`$(…)`).
119
+
120
+ For each command, the rule follows its command word through transparent wrappers, the way
121
+ `ShellSourceCode.resolveInvokedCommand` does. The default wrappers (`command`, `exec`, `env`,
122
+ `nohup`, `nice`, `time`, `timeout`, `sudo`) are followed, and so is your wrapper. The result is a
123
+ chain, outermost first: `retry sudo curl x` runs `retry`, then `sudo`, then `curl`.
124
+
125
+ - A listed command is **wrapped** when your wrapper appears earlier in its chain. So
126
+ `retry curl x` and `retry sudo curl x` are wrapped, and so is `sudo retry curl x`.
127
+ - `sudo curl x` is **not** wrapped: `sudo` is not your wrapper.
128
+ - A command substitution is a separate command. In `retry echo "$(curl x)"`, `retry` wraps `echo`,
129
+ and the `curl` inside `$(…)` is reported.
130
+ - `retry echo; curl x` is two commands, and `curl x` is reported.
131
+
132
+ ### Declaring the wrapper's options
133
+
134
+ A wrapper given as a string is assumed to take no options of its own, so the word after it is
135
+ the command it runs. When your wrapper has options, give a `WrapperSpec` (from `sh-ast/analyze`,
136
+ re-exported by this package) that declares them:
137
+
138
+ ```js
139
+ wrapper: { names: ['retry'], argFlags: ['-n'] } // retry -n 3 curl x → curl, wrapped
140
+ ```
141
+
142
+ `noArgFlags` lists options without an operand and `argFlags` options with one. An `argFlags`
143
+ option is recognized as `-n 3` and `-n3`, and a `--long` one also as `--long=value`. Every listed
144
+ `names` entry counts as the wrapper. A wrapper string that names one of the default wrappers,
145
+ such as `timeout`, uses that wrapper's known option shape (`timeout 10 curl x`).
146
+
147
+ ### What is never guessed
148
+
149
+ The rule reports only what it can determine from the source:
150
+
151
+ - A dynamic command word (`"$client" x`) is not a listed command.
152
+ - When the chain reaches an option your wrapper does not declare, the command it runs is unknown,
153
+ and nothing is reported. Declare the option to have the command checked.
154
+ - When a command restricted to subcommands has an undeclared option or a dynamic word before
155
+ its subcommand (`git "$op"`), the subcommand is unknown, and nothing is reported.
156
+ - Command strings run by another shell (`bash -c 'curl x'`) are text, not commands, and are not
157
+ checked.
158
+
159
+ ### Functions the file defines
160
+
161
+ A call to a function the file defines is not reported, wherever the function is defined (its
162
+ definition may come after the call). `curl() { retry command curl "$@"; }` followed by `curl x`
163
+ runs the function, not the external `curl`. This matches how
164
+ [`sh/no-undeclared-commands`](./rules/README.md) treats in-file functions. Only the command word
165
+ itself is looked up as a function. A real invocation inside the function (`command curl x`), a
166
+ call behind another wrapper (`sudo curl x`), and a call by path (`/usr/bin/curl x`) run the
167
+ external command and are still reported.
168
+
169
+ ### Local-only invocations
170
+
171
+ Some invocations of a listed command never leave the machine. `localInvocations` names them,
172
+ keyed by the command as the message names it (`curl`, or `git clone` for a command with a
173
+ subcommand), so the factory itself knows no command:
174
+
175
+ - `argLists` are complete argument lists that are local. They match exactly:
176
+ `{ curl: { argLists: [['--version'], ['--help']] } }` allows `curl --version` and `curl --help`
177
+ but still reports `curl --version https://example.com`, which makes the request.
178
+ - `localOperand` makes the invocation local when its first operand, found after the declared
179
+ `noArgFlags` and `argFlags` (a `--` ends the options), is a static word starting with one of
180
+ `localPrefixes`. `{ 'git clone': { localOperand: { localPrefixes: ['./', '../', '/', 'file://'] } } }`
181
+ allows `git clone ./x` and `git clone --depth 1 /srv/x.git`. Only the operand decides: words
182
+ after it (`git clone ./x "$dest"`), and the value of a declared option (`--depth "$n"`), may be
183
+ dynamic. An operand that is dynamic, or that follows an option the entry does not declare, is
184
+ not known to be local and is reported. The whole argument list up to a literal `--` is read, not
185
+ only up to the operand, because options may follow operands (`git clone ./x --recurse-submodules`):
186
+ an option the entry does not declare anywhere before `--` makes the invocation not local, and
187
+ words after `--` are operands. Leave an option out of the entry when it can make the
188
+ command reach elsewhere (`--recurse-submodules`).
189
+
190
+ Anything that is not provably local is reported.
191
+
192
+ ### Commands run by path
193
+
194
+ A command word that contains a slash is matched by its basename, so running a command by path
195
+ cannot bypass the policy. `/usr/bin/curl x` and `./bin/curl x` are `curl`, and the message names
196
+ `curl`. The same holds for wrappers: `/usr/local/bin/retry curl x` is `curl` run through `retry`,
197
+ with `retry`'s declared options, and `/usr/bin/sudo curl x` is `curl` run through `sudo`. Only the
198
+ whole basename is compared: `/usr/bin/curly` is not `curl`.
199
+
200
+ ### Subcommands
201
+
202
+ `subcommands` restricts a listed command to some subcommands. The subcommand is the word the
203
+ command runs after its own options, located the way a wrapped command is located: options in
204
+ `noArgFlags` and `argFlags` are skipped, and an option in `stopsChainFlags` means no subcommand
205
+ runs. The message names the command with its subcommand: `` `git fetch` must be run through
206
+ `retry`. ``