@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,166 @@
1
+ import type { WrapperSpec } from 'sh-ast/analyze';
2
+ import type { ShellRule } from './define-shell-rule.js';
3
+ /**
4
+ * Restricts a listed command to some of its subcommands, for commands such as
5
+ * `git` where only some subcommands are in scope (`git fetch` is a network
6
+ * call, `git status` is not).
7
+ *
8
+ * The flag fields are `WrapperSpec`'s own, picked from it so the two cannot
9
+ * drift, and describe the command's options that may appear before the
10
+ * subcommand (`git -C dir fetch`): `noArgFlags` take no operand (`--bare`),
11
+ * `argFlags` take one (`-C <dir>`), and `stopsChainFlags` mean the command
12
+ * runs no subcommand at all (`git --version fetch` prints the version, and is
13
+ * never reported). The subcommand is found the way a wrapped command is:
14
+ * declared options are skipped, and a flag the command does not declare
15
+ * leaves the subcommand unknown, so the invocation is not reported.
16
+ *
17
+ * @public
18
+ */
19
+ export interface WrapperRuleSubcommands extends Pick<WrapperSpec, 'noArgFlags' | 'argFlags' | 'stopsChainFlags'> {
20
+ /** The subcommands that make an invocation of the command count, such as `['fetch', 'push']`. */
21
+ readonly names: readonly string[];
22
+ }
23
+ /**
24
+ * Names the operand of a listed command whose value decides whether the
25
+ * invocation can leave the machine, such as the source of `git clone`.
26
+ *
27
+ * The flags describe the options that may precede the operand, with the same
28
+ * meaning as {@link WrapperRuleSubcommands}'s: `noArgFlags` take no value and
29
+ * `argFlags` take one (as `--depth 1`, or `--depth=1` in one word). The
30
+ * operand is the first word after them, or after a `--`. A flag that is not
31
+ * declared leaves the operand unknown, and an unknown operand is never taken
32
+ * as local.
33
+ *
34
+ * @public
35
+ */
36
+ export interface WrapperRuleLocalOperand extends Pick<WrapperSpec, 'noArgFlags' | 'argFlags'> {
37
+ /**
38
+ * The operand counts as local when it is a static word starting with one
39
+ * of these, such as `['./', '../', '/', 'file://']`.
40
+ */
41
+ readonly localPrefixes: readonly string[];
42
+ }
43
+ /**
44
+ * Ways to invoke one listed command that only act locally, so the policy has
45
+ * nothing to guard. An invocation matching either field is not reported.
46
+ *
47
+ * @public
48
+ */
49
+ export interface WrapperRuleLocalInvocation {
50
+ /**
51
+ * Complete argument lists, after the command (and its subcommand, if it has
52
+ * one), that make the invocation local. They match exactly: with
53
+ * `[['--version']]`, `curl --version` is local and
54
+ * `curl --version https://example.com` is not.
55
+ */
56
+ readonly argLists?: readonly (readonly string[])[];
57
+ /** Local when the command's source operand is a local path. */
58
+ readonly localOperand?: WrapperRuleLocalOperand;
59
+ }
60
+ /**
61
+ * What a "must be run through a wrapper" rule enforces. Passed to
62
+ * {@link createWrapperRule}.
63
+ *
64
+ * @public
65
+ */
66
+ export interface WrapperRuleConfig {
67
+ /**
68
+ * The command names that must be run through the wrapper. A static command
69
+ * word matches a name when it equals it, or when it contains a slash and
70
+ * its basename equals it (`/usr/bin/curl` and `./bin/curl` match `curl`).
71
+ */
72
+ readonly commands: readonly string[];
73
+ /**
74
+ * The wrapper the commands must be run through. A string names a wrapper
75
+ * that takes no options of its own (`retry curl x`), unless it names one of
76
+ * `sh-ast/analyze`'s default transparent wrappers (`timeout`, `nice`, …),
77
+ * whose known option shape is used. A `WrapperSpec` declares the wrapper's
78
+ * option shape, so that `retry -n 3 curl x` is recognized as `curl` run
79
+ * through `retry`; any of its `names` counts as the wrapper. Like
80
+ * `commands`, a wrapper run by path matches by basename
81
+ * (`/usr/local/bin/retry curl x`).
82
+ */
83
+ readonly wrapper: string | WrapperSpec;
84
+ /**
85
+ * Listed commands that count only with some subcommands, keyed by command
86
+ * name. A listed command with no entry here counts with any arguments.
87
+ */
88
+ readonly subcommands?: Readonly<Record<string, WrapperRuleSubcommands>>;
89
+ /**
90
+ * Invocations of a listed command that only act locally and so are not
91
+ * reported, keyed by the command as the rule's message names it: the listed
92
+ * name (`curl`), or the name with its subcommand (`git clone`).
93
+ */
94
+ readonly localInvocations?: Readonly<Record<string, WrapperRuleLocalInvocation>>;
95
+ /**
96
+ * The rule's `meta.docs` fields a rule pack owns. `url` defaults to this
97
+ * package's wrapper-rules docs page, and `description` to a sentence naming
98
+ * the commands and the wrapper. Set them to point users at the pack's own
99
+ * documentation.
100
+ */
101
+ readonly docs?: {
102
+ readonly url?: string;
103
+ readonly description?: string;
104
+ };
105
+ }
106
+ /**
107
+ * The message ids a wrapper rule reports with: `unwrappedCommand` names the
108
+ * command (with its subcommand, when one was matched) and the wrapper.
109
+ *
110
+ * @internal
111
+ */
112
+ export type WrapperRuleMessageId = 'unwrappedCommand';
113
+ /**
114
+ * The messages every wrapper rule reports with. Shared so that a rule which
115
+ * delegates to the factory (`sh/require-network-wrapper`) declares exactly the
116
+ * messages the factory's listeners report.
117
+ *
118
+ * @internal
119
+ */
120
+ export declare const WRAPPER_RULE_MESSAGES: Readonly<Record<WrapperRuleMessageId, string>>;
121
+ /**
122
+ * The wrapper-rule factory, typed for this package's rules: it is what
123
+ * {@link createWrapperRule} returns, with the shell-specific rule type and an
124
+ * options tuple chosen by the caller. A built-in rule whose config comes from
125
+ * its own options (`sh/require-network-wrapper`) builds the factory's rule
126
+ * per config and calls its `create` with its own context, so the options
127
+ * tuple is that rule's. The factory's rule itself reads no options.
128
+ *
129
+ * @internal
130
+ */
131
+ export declare function buildWrapperRule<RuleOptions extends unknown[] = []>(config: WrapperRuleConfig): ShellRule<RuleOptions, WrapperRuleMessageId>;
132
+ /**
133
+ * Creates a rule that reports every listed command that is not run through
134
+ * `config.wrapper`, for a rule pack's "must be wrapped by X" policies:
135
+ *
136
+ * ```js
137
+ * import { createWrapperRule } from '@eslint-sh/eslint-plugin';
138
+ *
139
+ * export const requireAudit = createWrapperRule({
140
+ * commands: ['kubectl', 'terraform'],
141
+ * wrapper: 'audit-run',
142
+ * });
143
+ * ```
144
+ *
145
+ * The rule declares `meta.languages: ["sh/sh"]`, takes no options (the
146
+ * config is bound here), and reports with message id `unwrappedCommand`,
147
+ * naming the command and the wrapper. Its `meta.docs.url` and
148
+ * `meta.docs.description` default to this package's wrapper-rules page and a
149
+ * sentence naming the policy; `config.docs` overrides either. A listed
150
+ * command is wrapped when the wrapper encloses it in `sh-ast/analyze`'s
151
+ * transparent-wrapper chain, so `audit-run sudo kubectl apply` is wrapped.
152
+ * Command and wrapper words written as paths match by basename
153
+ * (`/usr/bin/kubectl`). Commands inside `$(…)`, functions, and pipelines are
154
+ * checked; a command whose name or subcommand is not static is not reported.
155
+ *
156
+ * @param config - The commands, the wrapper they must be run through, any
157
+ * subcommand restrictions, and optional `meta.docs` overrides.
158
+ * @returns A {@link ShellRule}: an ESLint rule for the `sh/sh` language,
159
+ * typed so a pack's own declarations need no `@eslint/core` import to name it.
160
+ * @throws TypeError when `config` is malformed (no `commands` array, an empty
161
+ * wrapper name, a subcommand restriction without `names`, or an empty or
162
+ * non-string `docs.url` / `docs.description`).
163
+ *
164
+ * @public
165
+ */
166
+ export declare function createWrapperRule(config: WrapperRuleConfig): ShellRule<[], 'unwrappedCommand'>;
@@ -0,0 +1,407 @@
1
+ /**
2
+ * `createWrapperRule`: the public factory for "command X must be run through
3
+ * wrapper Y" rules, such as network calls that must go through a retry or
4
+ * audit wrapper.
5
+ *
6
+ * Which commands need a wrapper, and which wrapper, is project policy, so this
7
+ * package ships no wrapper name. A rule pack binds both by calling the factory
8
+ * (`createWrapperRule({ commands: ['curl'], wrapper: 'with-retry' })`), and
9
+ * `sh/require-network-wrapper` is the built-in instance whose wrapper comes
10
+ * from the user's rule options.
11
+ *
12
+ * "Run through" is decided from `sh-ast/analyze`'s transparent-wrapper
13
+ * resolution, never by reading words locally: each command site's argv0 is
14
+ * followed through the default wrapper table (`sudo`, `env`, `nohup`, …), the
15
+ * bound wrapper, and any command with modeled subcommands, and the resulting
16
+ * chain says what runs and what it runs inside. A listed command is wrapped
17
+ * exactly when the bound wrapper appears earlier in that chain, so
18
+ * `retry curl x` and `retry sudo curl x` are wrapped, while `sudo curl x` and
19
+ * the `curl` in `retry echo "$(curl x)"` are not.
20
+ *
21
+ * Names are matched by basename when a static command word contains a slash,
22
+ * so a path cannot bypass the policy: `/usr/bin/curl x` is `curl`, and
23
+ * `/usr/local/bin/retry curl x` is `curl` run through `retry`. This holds for
24
+ * listed commands, the bound wrapper, and every transparent wrapper followed
25
+ * (`/usr/bin/sudo curl x` is `curl` run through `sudo`).
26
+ *
27
+ * A call to a function the file defines (anywhere in it) is not the external
28
+ * command, so a listed command word that names one is not reported; a listed
29
+ * command behind a wrapper (`command curl x`) runs the external command and
30
+ * still is. Invocations a pack names in `localInvocations` act only locally
31
+ * and are not reported either.
32
+ *
33
+ * Facts that cannot be known statically are never guessed: a site whose chain
34
+ * stops at a dynamic word or an unrecognized wrapper flag before reaching a
35
+ * listed command does not report, and neither does a subcommand that is not
36
+ * static. Commands inside command strings (`bash -c '…'`) are not commands to
37
+ * this analysis and are out of scope.
38
+ */
39
+ import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
40
+ import { definedFunctionNames } from '../command-harvest/harvest-commands.js';
41
+ import { createRule, packageDocsUrl } from './create-rule.js';
42
+ /**
43
+ * The messages every wrapper rule reports with. Shared so that a rule which
44
+ * delegates to the factory (`sh/require-network-wrapper`) declares exactly the
45
+ * messages the factory's listeners report.
46
+ *
47
+ * @internal
48
+ */
49
+ export const WRAPPER_RULE_MESSAGES = {
50
+ unwrappedCommand: '`{{command}}` must be run through `{{wrapper}}`.',
51
+ };
52
+ /**
53
+ * The rule name `createRule` receives for every factory-made rule. A
54
+ * factory-made rule is not in the registry and always passes its own
55
+ * `docsUrl`, so the name is never used to derive a URL.
56
+ */
57
+ const WRAPPER_RULE_NAME = 'wrapper-rule';
58
+ /** The default `meta.docs.url` of a factory-made rule: the page documenting the factory. */
59
+ const WRAPPER_RULES_DOCS_URL = packageDocsUrl('wrapper-rules.md');
60
+ /**
61
+ * `name` when it is in `names`; otherwise, when `name` contains a slash, its
62
+ * basename when that is in `names`; otherwise `null`. This is the one place a
63
+ * command word is compared with a configured name, so a path to a configured
64
+ * command (`/usr/bin/curl`, `./bin/curl`) can never bypass the policy.
65
+ */
66
+ function matchName(name, names) {
67
+ if (names.has(name))
68
+ return name;
69
+ const slash = name.lastIndexOf('/');
70
+ if (slash === -1)
71
+ return null;
72
+ const base = name.slice(slash + 1);
73
+ return names.has(base) ? base : null;
74
+ }
75
+ /** `true` when `value` is a non-null, non-array object. */
76
+ function isRecord(value) {
77
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
78
+ }
79
+ /** `true` when `value` is a non-empty string. */
80
+ function isNonEmptyString(value) {
81
+ return typeof value === 'string' && value.length > 0;
82
+ }
83
+ /** `true` when `value` is an array of non-empty strings. */
84
+ function isStringArray(value) {
85
+ return (Array.isArray(value) &&
86
+ value.every((item) => typeof item === 'string' && item.length > 0));
87
+ }
88
+ /**
89
+ * Checks `config` and builds its lookup form. Throws `TypeError` naming the
90
+ * offending field, so a malformed binding fails when a rule pack is loaded
91
+ * rather than in the middle of linting a file.
92
+ */
93
+ function compileWrapperRule(config) {
94
+ const { commands, wrapper, subcommands = {}, localInvocations = {}, docs = {}, } = config;
95
+ if (!isStringArray(commands)) {
96
+ throw new TypeError('createWrapperRule: `commands` must be an array of non-empty strings');
97
+ }
98
+ for (const field of ['url', 'description']) {
99
+ if (docs[field] !== undefined && !isNonEmptyString(docs[field])) {
100
+ throw new TypeError(`createWrapperRule: \`docs.${field}\` must be a non-empty string`);
101
+ }
102
+ }
103
+ let wrapperSpec;
104
+ if (typeof wrapper === 'string' && wrapper.length > 0) {
105
+ wrapperSpec = DEFAULT_TRANSPARENT_WRAPPERS.find((spec) => spec.names.includes(wrapper)) ?? {
106
+ names: [wrapper],
107
+ };
108
+ }
109
+ else if (typeof wrapper === 'object' &&
110
+ isStringArray(wrapper.names) &&
111
+ wrapper.names.length > 0) {
112
+ wrapperSpec = wrapper;
113
+ }
114
+ else {
115
+ throw new TypeError('createWrapperRule: `wrapper` must be a non-empty string or a WrapperSpec with non-empty `names`');
116
+ }
117
+ const subcommandSpecs = [];
118
+ const subcommandNames = new Map();
119
+ for (const [command, restriction] of Object.entries(subcommands)) {
120
+ if (!isStringArray(restriction.names) || restriction.names.length === 0) {
121
+ throw new TypeError(`createWrapperRule: \`subcommands.${command}.names\` must be a non-empty array of non-empty strings`);
122
+ }
123
+ subcommandNames.set(command, new Set(restriction.names));
124
+ // Modeling the command as a transparent wrapper makes `resolveArgv0`
125
+ // locate its subcommand exactly as it locates a wrapped command: the
126
+ // declared options are skipped, and an undeclared flag stops the chain.
127
+ subcommandSpecs.push({
128
+ names: [command],
129
+ ...(restriction.noArgFlags && { noArgFlags: restriction.noArgFlags }),
130
+ ...(restriction.argFlags && { argFlags: restriction.argFlags }),
131
+ ...(restriction.stopsChainFlags && { stopsChainFlags: restriction.stopsChainFlags }),
132
+ });
133
+ }
134
+ for (const [label, local] of Object.entries(localInvocations)) {
135
+ const where = `localInvocations.${label}`;
136
+ if (!isRecord(local)) {
137
+ throw new TypeError(`createWrapperRule: \`${where}\` must be an object`);
138
+ }
139
+ const { argLists, localOperand } = local;
140
+ if (argLists !== undefined &&
141
+ !(Array.isArray(argLists) &&
142
+ argLists.every((list) => Array.isArray(list) && isStringArray(list)))) {
143
+ throw new TypeError(`createWrapperRule: \`${where}.argLists\` must be an array of arrays of non-empty strings`);
144
+ }
145
+ if (localOperand === undefined)
146
+ continue;
147
+ if (!isRecord(localOperand)) {
148
+ throw new TypeError(`createWrapperRule: \`${where}.localOperand\` must be an object`);
149
+ }
150
+ for (const field of ['localPrefixes', 'noArgFlags', 'argFlags']) {
151
+ const value = localOperand[field];
152
+ const valid = field === 'localPrefixes'
153
+ ? isStringArray(value)
154
+ : value === undefined || isStringArray(value);
155
+ if (!valid) {
156
+ throw new TypeError(`createWrapperRule: \`${where}.localOperand.${field}\` must be an array of non-empty strings`);
157
+ }
158
+ }
159
+ }
160
+ // `resolveArgv0` uses the first entry whose names match, so an entry this
161
+ // rule supplies replaces any default entry with the same name rather than
162
+ // competing with it.
163
+ const supplied = [wrapperSpec, ...subcommandSpecs];
164
+ const suppliedNames = new Set(supplied.flatMap((spec) => spec.names));
165
+ const transparentWrappers = [
166
+ ...supplied,
167
+ ...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => !spec.names.some((name) => suppliedNames.has(name))),
168
+ ];
169
+ const wrapperLabel = wrapperSpec.names.join('` or `');
170
+ const listed = commands.map((command) => `\`${command}\``).join(', ');
171
+ return {
172
+ commands: new Set(commands),
173
+ docsUrl: docs.url ?? WRAPPER_RULES_DOCS_URL,
174
+ description: docs.description ?? `Require ${listed} to be run through \`${wrapperLabel}\``,
175
+ wrapperNames: new Set(wrapperSpec.names),
176
+ wrapperLabel,
177
+ subcommands: subcommandNames,
178
+ localInvocations: new Map(Object.entries(localInvocations)),
179
+ transparentWrappers,
180
+ };
181
+ }
182
+ /** The static text of a chain word, or `null` when it is not static. */
183
+ function staticText(word) {
184
+ return word?.static === true ? word.text : null;
185
+ }
186
+ /**
187
+ * Resolves `site`'s chain through `compiled`'s wrapper table, following a
188
+ * wrapper invoked by path as the wrapper it names.
189
+ *
190
+ * `resolveArgv0` matches wrapper names exactly, so a path to a wrapper
191
+ * (`/usr/local/bin/retry`) ends the chain as if it were the command run. When
192
+ * the chain ends at a static word with a slash whose basename names a table
193
+ * entry, the site is resolved again with that entry also recognized under the
194
+ * exact path text, so its options and wrapped command are located by the
195
+ * same analysis. Each pass adds one new name drawn from the site's own argv,
196
+ * so the loop ends.
197
+ */
198
+ function resolveChain(compiled, site) {
199
+ let table = compiled.transparentWrappers;
200
+ for (;;) {
201
+ const resolution = site.resolveInvokedCommand({ transparentWrappers: table });
202
+ const last = staticText(resolution.chain.at(-1));
203
+ if (last === null || table.some((spec) => spec.names.includes(last)))
204
+ return resolution;
205
+ const spec = compiled.transparentWrappers.find((entry) => matchName(last, new Set(entry.names)) !== null);
206
+ if (spec === undefined)
207
+ return resolution;
208
+ table = [{ ...spec, names: [last] }, ...table];
209
+ }
210
+ }
211
+ /**
212
+ * Builds the listeners that enforce `compiled` for one file. Every command
213
+ * site in the file, including those inside command substitutions and
214
+ * functions, is checked once.
215
+ */
216
+ function wrapperRuleListeners(compiled, context) {
217
+ const { sourceCode } = context;
218
+ return {
219
+ File() {
220
+ let functions;
221
+ for (const site of sourceCode.getCommandSites()) {
222
+ const resolution = resolveChain(compiled, site);
223
+ const finding = findListedCommand(compiled, resolution.chain);
224
+ if (finding === null || finding.wrapped)
225
+ continue;
226
+ // A function the file defines runs instead of the external command of
227
+ // the same name, wherever it is defined. Only the command word itself
228
+ // is looked up as a function; behind a wrapper (`command curl`) the
229
+ // external command runs.
230
+ if (finding.index === 0) {
231
+ functions ??= definedFunctionNames(sourceCode);
232
+ if (functions.has(finding.word))
233
+ continue;
234
+ }
235
+ if (isLocalInvocation(compiled, finding, resolution, site))
236
+ continue;
237
+ context.report({
238
+ node: site.node,
239
+ messageId: 'unwrappedCommand',
240
+ data: { command: finding.command, wrapper: compiled.wrapperLabel },
241
+ });
242
+ }
243
+ },
244
+ };
245
+ }
246
+ /**
247
+ * The first operand of `args`, once every option of `operand` has been read,
248
+ * or `null` when the invocation cannot be shown to use only that operand.
249
+ *
250
+ * The whole argument list up to a literal `--` is scanned, not just up to the
251
+ * operand, because commands such as `git clone` accept options after their
252
+ * operands (`git clone ./x --recurse-submodules`). Every option must be one
253
+ * the entry declares; an undeclared option anywhere before `--`, a dynamic word
254
+ * where an option or the first operand would stand, or a missing or dynamic
255
+ * operand gives `null`. Words after `--` are operands and are not read.
256
+ * `args` holds each word's static text, or `null` for a dynamic word. A dynamic
257
+ * word is tolerated as an option's value (`--depth "$n"`) and after the first
258
+ * operand (`git clone ./x "$dest"`), where it cannot change which operand
259
+ * comes first.
260
+ */
261
+ function firstOperand(args, operand) {
262
+ const noArg = new Set(operand.noArgFlags ?? []);
263
+ const withArg = new Set(operand.argFlags ?? []);
264
+ let first;
265
+ for (let i = 0; i < args.length; i++) {
266
+ const arg = args[i];
267
+ if (arg === null) {
268
+ if (first === undefined)
269
+ return null;
270
+ continue;
271
+ }
272
+ if (arg === '--') {
273
+ return first === undefined ? (args[i + 1] ?? null) : first;
274
+ }
275
+ if (!arg.startsWith('-') || arg === '-') {
276
+ first ??= arg;
277
+ continue;
278
+ }
279
+ if (noArg.has(arg))
280
+ continue;
281
+ if (withArg.has(arg)) {
282
+ i++;
283
+ continue;
284
+ }
285
+ const equals = arg.indexOf('=');
286
+ if (arg.startsWith('--') && equals !== -1 && withArg.has(arg.slice(0, equals)))
287
+ continue;
288
+ // A short option with its value attached (`-j4`).
289
+ if (!arg.startsWith('--') && arg.length > 2 && withArg.has(arg.slice(0, 2)))
290
+ continue;
291
+ return null;
292
+ }
293
+ return first ?? null;
294
+ }
295
+ /**
296
+ * Whether the invocation `finding` describes only acts locally, according to
297
+ * the {@link WrapperRuleLocalInvocation} bound to its command. Anything
298
+ * not provably local is not local.
299
+ */
300
+ function isLocalInvocation(compiled, finding, resolution, site) {
301
+ const local = compiled.localInvocations.get(finding.command);
302
+ if (local === undefined)
303
+ return false;
304
+ const start = resolution.chainIndices[finding.lastIndex];
305
+ const args = site.argv.slice(start + 1).map((word) => (word.static ? word.text : null));
306
+ if (local.argLists?.some((list) => list.length === args.length && list.every((a, i) => a === args[i]))) {
307
+ return true;
308
+ }
309
+ if (local.localOperand === undefined)
310
+ return false;
311
+ const source = firstOperand(args, local.localOperand);
312
+ return (source !== null && local.localOperand.localPrefixes.some((prefix) => source.startsWith(prefix)));
313
+ }
314
+ /**
315
+ * Finds the first listed command in `chain` (outermost first) that counts,
316
+ * and whether the wrapper encloses it. Command and wrapper words are compared
317
+ * with {@link matchName}. A command restricted to subcommands counts only
318
+ * when the next chain word is exactly one of them. The scan stops at the
319
+ * first word that is not static: `resolveArgv0` never resolves past one.
320
+ */
321
+ function findListedCommand(compiled, chain) {
322
+ for (const [index, word] of chain.entries()) {
323
+ const text = staticText(word);
324
+ if (text === null)
325
+ return null;
326
+ const name = matchName(text, compiled.commands);
327
+ if (name === null)
328
+ continue;
329
+ let command = name;
330
+ let lastIndex = index;
331
+ const allowed = compiled.subcommands.get(name);
332
+ if (allowed !== undefined) {
333
+ const subcommand = staticText(chain[index + 1]);
334
+ if (subcommand === null || !allowed.has(subcommand))
335
+ continue;
336
+ command = `${name} ${subcommand}`;
337
+ lastIndex = index + 1;
338
+ }
339
+ const wrapped = chain.slice(0, index).some((outer) => {
340
+ const outerName = staticText(outer);
341
+ return outerName !== null && matchName(outerName, compiled.wrapperNames) !== null;
342
+ });
343
+ return { command, wrapped, index, word: text, lastIndex };
344
+ }
345
+ return null;
346
+ }
347
+ /**
348
+ * The wrapper-rule factory, typed for this package's rules: it is what
349
+ * {@link createWrapperRule} returns, with the shell-specific rule type and an
350
+ * options tuple chosen by the caller. A built-in rule whose config comes from
351
+ * its own options (`sh/require-network-wrapper`) builds the factory's rule
352
+ * per config and calls its `create` with its own context, so the options
353
+ * tuple is that rule's. The factory's rule itself reads no options.
354
+ *
355
+ * @internal
356
+ */
357
+ export function buildWrapperRule(config) {
358
+ const compiled = compileWrapperRule(config);
359
+ return createRule({
360
+ name: WRAPPER_RULE_NAME,
361
+ docsUrl: compiled.docsUrl,
362
+ meta: {
363
+ type: 'problem',
364
+ docs: { description: compiled.description },
365
+ schema: [],
366
+ messages: WRAPPER_RULE_MESSAGES,
367
+ },
368
+ create: (context) => wrapperRuleListeners(compiled, context),
369
+ });
370
+ }
371
+ /**
372
+ * Creates a rule that reports every listed command that is not run through
373
+ * `config.wrapper`, for a rule pack's "must be wrapped by X" policies:
374
+ *
375
+ * ```js
376
+ * import { createWrapperRule } from '@eslint-sh/eslint-plugin';
377
+ *
378
+ * export const requireAudit = createWrapperRule({
379
+ * commands: ['kubectl', 'terraform'],
380
+ * wrapper: 'audit-run',
381
+ * });
382
+ * ```
383
+ *
384
+ * The rule declares `meta.languages: ["sh/sh"]`, takes no options (the
385
+ * config is bound here), and reports with message id `unwrappedCommand`,
386
+ * naming the command and the wrapper. Its `meta.docs.url` and
387
+ * `meta.docs.description` default to this package's wrapper-rules page and a
388
+ * sentence naming the policy; `config.docs` overrides either. A listed
389
+ * command is wrapped when the wrapper encloses it in `sh-ast/analyze`'s
390
+ * transparent-wrapper chain, so `audit-run sudo kubectl apply` is wrapped.
391
+ * Command and wrapper words written as paths match by basename
392
+ * (`/usr/bin/kubectl`). Commands inside `$(…)`, functions, and pipelines are
393
+ * checked; a command whose name or subcommand is not static is not reported.
394
+ *
395
+ * @param config - The commands, the wrapper they must be run through, any
396
+ * subcommand restrictions, and optional `meta.docs` overrides.
397
+ * @returns A {@link ShellRule}: an ESLint rule for the `sh/sh` language,
398
+ * typed so a pack's own declarations need no `@eslint/core` import to name it.
399
+ * @throws TypeError when `config` is malformed (no `commands` array, an empty
400
+ * wrapper name, a subcommand restriction without `names`, or an empty or
401
+ * non-string `docs.url` / `docs.description`).
402
+ *
403
+ * @public
404
+ */
405
+ export function createWrapperRule(config) {
406
+ return buildWrapperRule(config);
407
+ }