@eslint-sh/eslint-plugin 0.5.0 → 0.6.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 (256) hide show
  1. package/README.md +223 -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 +50 -0
  18. package/dist/configs/posix-portable.d.ts +40 -0
  19. package/dist/configs/posix-portable.js +52 -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-case-attributes.d.ts +12 -0
  66. package/dist/rules/compat/no-case-attributes.js +63 -0
  67. package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
  68. package/dist/rules/compat/no-case-fallthrough.js +67 -0
  69. package/dist/rules/compat/no-case-modification.d.ts +35 -0
  70. package/dist/rules/compat/no-case-modification.js +86 -0
  71. package/dist/rules/compat/no-coproc.d.ts +12 -0
  72. package/dist/rules/compat/no-coproc.js +47 -0
  73. package/dist/rules/compat/no-declare-g.d.ts +12 -0
  74. package/dist/rules/compat/no-declare-g.js +59 -0
  75. package/dist/rules/compat/no-double-bracket.d.ts +12 -0
  76. package/dist/rules/compat/no-double-bracket.js +52 -0
  77. package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
  78. package/dist/rules/compat/no-epoch-variables.js +57 -0
  79. package/dist/rules/compat/no-fd-variable.d.ts +12 -0
  80. package/dist/rules/compat/no-fd-variable.js +48 -0
  81. package/dist/rules/compat/no-globstar.d.ts +12 -0
  82. package/dist/rules/compat/no-globstar.js +39 -0
  83. package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
  84. package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
  85. package/dist/rules/compat/no-let.d.ts +12 -0
  86. package/dist/rules/compat/no-let.js +62 -0
  87. package/dist/rules/compat/no-mapfile.d.ts +13 -0
  88. package/dist/rules/compat/no-mapfile.js +122 -0
  89. package/dist/rules/compat/no-nameref.d.ts +12 -0
  90. package/dist/rules/compat/no-nameref.js +44 -0
  91. package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
  92. package/dist/rules/compat/no-negative-array-index.js +123 -0
  93. package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
  94. package/dist/rules/compat/no-nounset-empty-array.js +158 -0
  95. package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
  96. package/dist/rules/compat/no-parameter-transformation.js +123 -0
  97. package/dist/rules/compat/no-pipe-all.d.ts +13 -0
  98. package/dist/rules/compat/no-pipe-all.js +68 -0
  99. package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
  100. package/dist/rules/compat/no-printf-time-format.js +99 -0
  101. package/dist/rules/compat/no-source.d.ts +13 -0
  102. package/dist/rules/compat/no-source.js +103 -0
  103. package/dist/rules/compat/no-test-v.d.ts +12 -0
  104. package/dist/rules/compat/no-test-v.js +280 -0
  105. package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
  106. package/dist/rules/compat/no-unicode-escape.js +120 -0
  107. package/dist/rules/compat/no-wait-n.d.ts +12 -0
  108. package/dist/rules/compat/no-wait-n.js +73 -0
  109. package/dist/rules/compat/operator-range.d.ts +10 -0
  110. package/dist/rules/compat/operator-range.js +15 -0
  111. package/dist/rules/compat/posix-absent-command.d.ts +12 -0
  112. package/dist/rules/compat/posix-absent-command.js +134 -0
  113. package/dist/rules/compat/shopt-options.d.ts +9 -0
  114. package/dist/rules/compat/shopt-options.js +63 -0
  115. package/dist/rules/compat/version-gates.d.ts +76 -0
  116. package/dist/rules/compat/version-gates.js +197 -0
  117. package/dist/rules/create-rule.d.ts +91 -0
  118. package/dist/rules/create-rule.js +51 -0
  119. package/dist/rules/create-wrapper-rule.d.ts +166 -0
  120. package/dist/rules/create-wrapper-rule.js +407 -0
  121. package/dist/rules/define-shell-rule.d.ts +184 -0
  122. package/dist/rules/define-shell-rule.js +56 -0
  123. package/dist/rules/index.d.ts +26 -0
  124. package/dist/rules/index.js +88 -0
  125. package/dist/rules/max-script-complexity.d.ts +28 -0
  126. package/dist/rules/max-script-complexity.js +130 -0
  127. package/dist/rules/no-dynamic-invocation.d.ts +35 -0
  128. package/dist/rules/no-dynamic-invocation.js +117 -0
  129. package/dist/rules/no-eval.d.ts +13 -0
  130. package/dist/rules/no-eval.js +89 -0
  131. package/dist/rules/no-masked-exit-status.d.ts +21 -0
  132. package/dist/rules/no-masked-exit-status.js +667 -0
  133. package/dist/rules/no-secret-env-reads.d.ts +24 -0
  134. package/dist/rules/no-secret-env-reads.js +133 -0
  135. package/dist/rules/no-secret-flags.d.ts +32 -0
  136. package/dist/rules/no-secret-flags.js +162 -0
  137. package/dist/rules/no-unchecked-cd.d.ts +44 -0
  138. package/dist/rules/no-unchecked-cd.js +250 -0
  139. package/dist/rules/no-undeclared-commands.d.ts +20 -0
  140. package/dist/rules/no-undeclared-commands.js +115 -0
  141. package/dist/rules/no-unused-declarations.d.ts +23 -0
  142. package/dist/rules/no-unused-declarations.js +259 -0
  143. package/dist/rules/prefer-local.d.ts +16 -0
  144. package/dist/rules/prefer-local.js +249 -0
  145. package/dist/rules/quote-expansions.d.ts +13 -0
  146. package/dist/rules/quote-expansions.js +229 -0
  147. package/dist/rules/require-network-wrapper.d.ts +40 -0
  148. package/dist/rules/require-network-wrapper.js +244 -0
  149. package/dist/rules/require-shebang.d.ts +29 -0
  150. package/dist/rules/require-shebang.js +101 -0
  151. package/dist/rules/require-strict-mode.d.ts +52 -0
  152. package/dist/rules/require-strict-mode.js +177 -0
  153. package/dist/rules/require-tty-guard.d.ts +13 -0
  154. package/dist/rules/require-tty-guard.js +289 -0
  155. package/dist/rules/resolve-declaration-convention.d.ts +96 -0
  156. package/dist/rules/resolve-declaration-convention.js +109 -0
  157. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  158. package/dist/rules/resolve-network-wrapper.js +116 -0
  159. package/dist/rules/set-options.d.ts +109 -0
  160. package/dist/rules/set-options.js +201 -0
  161. package/dist/rules/shebang-interpreter.d.ts +18 -0
  162. package/dist/rules/shebang-interpreter.js +81 -0
  163. package/dist/rules/shebang-matches-dialect.d.ts +12 -0
  164. package/dist/rules/shebang-matches-dialect.js +69 -0
  165. package/dist/rules/transparent-wrappers.d.ts +52 -0
  166. package/dist/rules/transparent-wrappers.js +99 -0
  167. package/dist/rules/user-pattern.d.ts +63 -0
  168. package/dist/rules/user-pattern.js +184 -0
  169. package/dist/syntax/analyze.d.ts +19 -0
  170. package/dist/syntax/analyze.js +21 -0
  171. package/dist/syntax/index.d.ts +23 -0
  172. package/dist/syntax/index.js +25 -0
  173. package/dist/version.d.ts +0 -1
  174. package/dist/version.js +0 -1
  175. package/docs/compat.md +347 -0
  176. package/docs/custom-rule-tutorial.md +780 -34
  177. package/docs/declaration-conventions.md +478 -0
  178. package/docs/examples/audit-wrapper-pack.mjs +15 -0
  179. package/docs/examples/dep-line-convention.mjs +22 -0
  180. package/docs/examples/eslint.config.mjs +3 -2
  181. package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
  182. package/docs/examples/no-backticks-rule-tester.mjs +106 -0
  183. package/docs/examples/no-backticks-rule.mjs +60 -0
  184. package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
  185. package/docs/examples/no-function-keyword-rule.mjs +59 -0
  186. package/docs/examples/no-listed-commands-rule.ts +63 -0
  187. package/docs/examples/no-rm-rf-rule.mjs +26 -15
  188. package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
  189. package/docs/examples/sidecar-convention.mjs +38 -0
  190. package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
  191. package/docs/examples/sorted-declarations-rule.mjs +52 -0
  192. package/docs/reading-nodes.md +159 -0
  193. package/docs/rules/README.md +78 -0
  194. package/docs/rules/_template.md +83 -0
  195. package/docs/rules/compat/no-append-all-redirect.md +87 -0
  196. package/docs/rules/compat/no-associative-arrays.md +95 -0
  197. package/docs/rules/compat/no-case-attributes.md +82 -0
  198. package/docs/rules/compat/no-case-fallthrough.md +90 -0
  199. package/docs/rules/compat/no-case-modification.md +74 -0
  200. package/docs/rules/compat/no-coproc.md +76 -0
  201. package/docs/rules/compat/no-declare-g.md +80 -0
  202. package/docs/rules/compat/no-double-bracket.md +72 -0
  203. package/docs/rules/compat/no-epoch-variables.md +79 -0
  204. package/docs/rules/compat/no-fd-variable.md +78 -0
  205. package/docs/rules/compat/no-globstar.md +74 -0
  206. package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
  207. package/docs/rules/compat/no-let.md +78 -0
  208. package/docs/rules/compat/no-mapfile.md +118 -0
  209. package/docs/rules/compat/no-nameref.md +81 -0
  210. package/docs/rules/compat/no-negative-array-index.md +87 -0
  211. package/docs/rules/compat/no-nounset-empty-array.md +103 -0
  212. package/docs/rules/compat/no-parameter-transformation.md +94 -0
  213. package/docs/rules/compat/no-pipe-all.md +85 -0
  214. package/docs/rules/compat/no-printf-time-format.md +75 -0
  215. package/docs/rules/compat/no-source.md +98 -0
  216. package/docs/rules/compat/no-test-v.md +82 -0
  217. package/docs/rules/compat/no-unicode-escape.md +91 -0
  218. package/docs/rules/compat/no-wait-n.md +85 -0
  219. package/docs/rules/max-script-complexity.md +109 -0
  220. package/docs/rules/no-dynamic-invocation.md +139 -0
  221. package/docs/rules/no-eval.md +106 -0
  222. package/docs/rules/no-masked-exit-status.md +239 -0
  223. package/docs/rules/no-secret-env-reads.md +173 -0
  224. package/docs/rules/no-secret-flags.md +169 -0
  225. package/docs/rules/no-unchecked-cd.md +146 -0
  226. package/docs/rules/no-undeclared-commands.md +141 -0
  227. package/docs/rules/no-unused-declarations.md +216 -0
  228. package/docs/rules/prefer-local.md +221 -0
  229. package/docs/rules/quote-expansions.md +157 -0
  230. package/docs/rules/require-network-wrapper.md +171 -0
  231. package/docs/rules/require-shebang.md +157 -0
  232. package/docs/rules/require-strict-mode.md +253 -0
  233. package/docs/rules/require-tty-guard.md +128 -0
  234. package/docs/rules/shebang-matches-dialect.md +120 -0
  235. package/docs/selectors.md +2 -2
  236. package/docs/shellcheck.md +86 -0
  237. package/docs/wrapper-rules.md +206 -0
  238. package/package.json +26 -20
  239. package/dist/index.d.ts.map +0 -1
  240. package/dist/index.js.map +0 -1
  241. package/dist/language/selector-classes.d.ts.map +0 -1
  242. package/dist/language/selector-classes.js.map +0 -1
  243. package/dist/language/shell-language-options.d.ts.map +0 -1
  244. package/dist/language/shell-language-options.js.map +0 -1
  245. package/dist/language/shell-language.d.ts.map +0 -1
  246. package/dist/language/shell-language.js.map +0 -1
  247. package/dist/language/shell-source-code.d.ts.map +0 -1
  248. package/dist/language/shell-source-code.js.map +0 -1
  249. package/dist/language/source-coordinates.d.ts.map +0 -1
  250. package/dist/language/source-coordinates.js.map +0 -1
  251. package/dist/language/syntax-view.d.ts.map +0 -1
  252. package/dist/language/syntax-view.js.map +0 -1
  253. package/dist/language/word-resolution.d.ts.map +0 -1
  254. package/dist/language/word-resolution.js.map +0 -1
  255. package/dist/version.d.ts.map +0 -1
  256. package/dist/version.js.map +0 -1
@@ -0,0 +1,52 @@
1
+ import type { ShellFile, ShellNodeOf } from '../language/syntax-view.js';
2
+ import type { ShellSourceCode } from '../language/shell-source-code.js';
3
+ import type { SetOptionChange } from './set-options.js';
4
+ /**
5
+ * The rule's single options object.
6
+ *
7
+ * @internal
8
+ */
9
+ export interface RequireStrictModeOptions {
10
+ /**
11
+ * The required prologue: either a `set` command (`"set -euo pipefail"`),
12
+ * whose enabled options become the requirement and whose text is what the
13
+ * autofix inserts, or the list of required long option names
14
+ * (`["errexit", "nounset"]`), for which the autofix inserts a `set` command
15
+ * spelled from that list. When omitted, it is `"set -eu"` for a file
16
+ * whose dialect is `posix` and `"set -euo pipefail"` for every other
17
+ * dialect.
18
+ */
19
+ readonly mode?: string | readonly string[];
20
+ /**
21
+ * When `true`, options passed to the shell on the shebang line
22
+ * (`#!/bin/bash -e`, `#!/usr/bin/env -S bash -euo pipefail`) count as
23
+ * enabled, so a shebang alone can satisfy the rule. Off by default, because
24
+ * those options are ignored when the script is run as `bash script.sh`.
25
+ */
26
+ readonly allowShebangOnly?: boolean;
27
+ /** How many top-level statements, from the top, the prologue may take to appear. */
28
+ readonly withinStatements?: number;
29
+ }
30
+ /**
31
+ * Whether `stmt` is a plain, foreground, top-level `set` command, and if so
32
+ * the option changes its arguments make (read through `sourceCode`, so a
33
+ * non-static argument ends the reading).
34
+ *
35
+ * @internal
36
+ */
37
+ export declare function setStatementChanges(stmt: ShellNodeOf<'Stmt'>, sourceCode: ShellSourceCode): readonly SetOptionChange[];
38
+ /**
39
+ * The options a file's prologue leaves enabled: the state after the first
40
+ * `withinStatements` top-level statements of `file`, plus the shebang's
41
+ * options when `allowShebangOnly` is set. This is the single definition of
42
+ * "what the prologue enables", shared with other rules (such as
43
+ * `no-unchecked-cd`) that need to know whether strict mode is in force.
44
+ *
45
+ * @internal
46
+ */
47
+ export declare function prologueEnabledOptions(file: ShellFile, sourceCode: ShellSourceCode, config?: {
48
+ readonly allowShebangOnly?: boolean;
49
+ readonly withinStatements?: number;
50
+ }): ReadonlySet<string>;
51
+ /** Built-in rule `sh/require-strict-mode`. @internal */
52
+ export declare const requireStrictMode: import("./define-shell-rule.js").ShellRule<[RequireStrictModeOptions], "missingStrictMode">;
@@ -0,0 +1,177 @@
1
+ import { createRule } from './create-rule.js';
2
+ import { SET_OPTION_NAMES, applySetOptionChanges, formatSetCommand, parseSetArguments, shebangOptionChanges, } from './set-options.js';
3
+ import { lineBreakOf } from '../language/line-breaks.js';
4
+ /**
5
+ * The prologue an unconfigured `mode` falls back to for every dialect except
6
+ * `posix`.
7
+ */
8
+ const DEFAULT_PROLOGUE = 'set -euo pipefail';
9
+ /**
10
+ * The prologue an unconfigured `mode` falls back to under the `posix`
11
+ * dialect: strict mode without `pipefail`, so the autofix never inserts a
12
+ * line that a pre-POSIX.1-2024 `sh` rejects. `pipefail` stays available
13
+ * there by configuring `mode`.
14
+ */
15
+ const POSIX_DEFAULT_PROLOGUE = 'set -eu';
16
+ /**
17
+ * The prologue used when `mode` is not configured, chosen by the file's
18
+ * dialect. Only `posix` differs; `bats` files run under Bash and keep the
19
+ * Bash default.
20
+ */
21
+ function defaultPrologueFor(dialect) {
22
+ return dialect === 'posix' ? POSIX_DEFAULT_PROLOGUE : DEFAULT_PROLOGUE;
23
+ }
24
+ /** How many top-level statements `withinStatements` defaults to. */
25
+ const DEFAULT_WITHIN_STATEMENTS = 5;
26
+ /**
27
+ * Turns the `mode` option into a {@link StrictModeRequirement}. A string mode
28
+ * must be a `set` command whose arguments only enable known options (no `+`
29
+ * flags, no positional parameters); anything else is a configuration error,
30
+ * reported by throwing so ESLint surfaces it instead of linting against a
31
+ * requirement nobody wrote.
32
+ */
33
+ function resolveRequirement(mode) {
34
+ if (typeof mode !== 'string') {
35
+ return { required: [...new Set(mode)], prologue: formatSetCommand(mode) };
36
+ }
37
+ const prologue = mode.trim();
38
+ const [command, ...args] = prologue.split(/\s+/u);
39
+ const parsed = parseSetArguments(args);
40
+ if (command !== 'set' ||
41
+ parsed.stoppedBy !== undefined ||
42
+ parsed.changes.length === 0 ||
43
+ parsed.changes.some((change) => !change.enable)) {
44
+ throw new TypeError(`sh/require-strict-mode: \`mode\` must be a \`set\` command that only enables options ` +
45
+ `(for example "${DEFAULT_PROLOGUE}") or a list of option names; got ${JSON.stringify(mode)}.`);
46
+ }
47
+ return { required: [...new Set(parsed.changes.map((change) => change.option))], prologue };
48
+ }
49
+ /**
50
+ * Whether `stmt` is a plain, foreground, top-level `set` command, and if so
51
+ * the option changes its arguments make (read through `sourceCode`, so a
52
+ * non-static argument ends the reading).
53
+ *
54
+ * @internal
55
+ */
56
+ export function setStatementChanges(stmt, sourceCode) {
57
+ if (stmt.background)
58
+ return [];
59
+ const { cmd } = stmt;
60
+ if (cmd?.type !== 'CallExpr' || sourceCode.getCommandName(cmd) !== 'set')
61
+ return [];
62
+ const args = cmd.args.slice(1);
63
+ return parseSetArguments(args.map((word) => sourceCode.resolveWordText(word))).changes;
64
+ }
65
+ /**
66
+ * The options enabled after applying `seed`, then each statement's `set`
67
+ * changes in order.
68
+ */
69
+ function enabledAfter(seed, statementChanges) {
70
+ const enabled = applySetOptionChanges(new Set(), seed);
71
+ for (const changes of statementChanges)
72
+ applySetOptionChanges(enabled, changes);
73
+ return enabled;
74
+ }
75
+ /**
76
+ * The options a file's prologue leaves enabled: the state after the first
77
+ * `withinStatements` top-level statements of `file`, plus the shebang's
78
+ * options when `allowShebangOnly` is set. This is the single definition of
79
+ * "what the prologue enables", shared with other rules (such as
80
+ * `no-unchecked-cd`) that need to know whether strict mode is in force.
81
+ *
82
+ * @internal
83
+ */
84
+ export function prologueEnabledOptions(file, sourceCode, config = {}) {
85
+ const shebangChanges = config.allowShebangOnly === true ? shebangOptionChanges(sourceCode.text) : [];
86
+ const statements = file.stmts.slice(0, config.withinStatements ?? DEFAULT_WITHIN_STATEMENTS);
87
+ return enabledAfter(shebangChanges, statements.map((stmt) => setStatementChanges(stmt, sourceCode)));
88
+ }
89
+ /** Built-in rule `sh/require-strict-mode`. @internal */
90
+ export const requireStrictMode = createRule({
91
+ name: 'require-strict-mode',
92
+ meta: {
93
+ type: 'problem',
94
+ docs: {
95
+ description: 'require the configured strict-mode options (`set -euo pipefail` by default, `set -eu` for POSIX) near the top of the file',
96
+ },
97
+ fixable: 'code',
98
+ schema: [
99
+ {
100
+ type: 'object',
101
+ properties: {
102
+ mode: {
103
+ oneOf: [
104
+ { type: 'string', minLength: 1 },
105
+ {
106
+ type: 'array',
107
+ items: { enum: [...SET_OPTION_NAMES] },
108
+ minItems: 1,
109
+ uniqueItems: true,
110
+ },
111
+ ],
112
+ },
113
+ allowShebangOnly: { type: 'boolean' },
114
+ withinStatements: { type: 'integer', minimum: 1 },
115
+ },
116
+ additionalProperties: false,
117
+ },
118
+ ],
119
+ // `mode` has no static default: an unconfigured `mode` resolves per file
120
+ // from the dialect (see `defaultPrologueFor`), so it must stay absent here
121
+ // for the rule to tell "not configured" apart from a configured value.
122
+ defaultOptions: [
123
+ {
124
+ allowShebangOnly: false,
125
+ withinStatements: DEFAULT_WITHIN_STATEMENTS,
126
+ },
127
+ ],
128
+ messages: {
129
+ missingStrictMode: 'Enable strict mode with `{{prologue}}` within {{window}}; not enabled: {{missing}}.',
130
+ },
131
+ },
132
+ create(context) {
133
+ const [options] = context.options;
134
+ const { sourceCode } = context;
135
+ const { required, prologue } = resolveRequirement(options.mode ?? defaultPrologueFor(sourceCode.dialect));
136
+ const withinStatements = options.withinStatements ?? DEFAULT_WITHIN_STATEMENTS;
137
+ return {
138
+ File(node) {
139
+ const text = sourceCode.text;
140
+ const shebangChanges = options.allowShebangOnly === true ? shebangOptionChanges(text) : [];
141
+ const statements = node.stmts.slice(0, withinStatements);
142
+ const statementChanges = statements.map((stmt) => setStatementChanges(stmt, sourceCode));
143
+ const enabled = enabledAfter(shebangChanges, statementChanges);
144
+ const missing = required.filter((option) => !enabled.has(option));
145
+ if (missing.length === 0)
146
+ return;
147
+ // With the prologue inserted as the new first statement, the window
148
+ // holds one fewer original statement. Fix only if that would pass.
149
+ const prologueChanges = required.map((option) => ({ option, enable: true }));
150
+ const fixedEnabled = enabledAfter([...shebangChanges, ...prologueChanges], statementChanges.slice(0, withinStatements - 1));
151
+ const fixSatisfies = required.every((option) => fixedEnabled.has(option));
152
+ context.report({
153
+ loc: { line: 1, column: 1 },
154
+ messageId: 'missingStrictMode',
155
+ data: {
156
+ prologue,
157
+ window: withinStatements === 1
158
+ ? 'the first statement'
159
+ : `the first ${String(withinStatements)} statements`,
160
+ missing: missing.join(', '),
161
+ },
162
+ fix: fixSatisfies
163
+ ? (fixer) => {
164
+ const eol = lineBreakOf(text);
165
+ if (!text.startsWith('#!'))
166
+ return fixer.insertTextBeforeRange([0, 0], prologue + eol);
167
+ const shebangEnd = text.search(/\r\n|\n|\r/u);
168
+ return shebangEnd === -1
169
+ ? fixer.insertTextAfterRange([0, text.length], eol + prologue)
170
+ : fixer.insertTextAfterRange([0, shebangEnd], eol + prologue);
171
+ }
172
+ : null,
173
+ });
174
+ },
175
+ };
176
+ },
177
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The options of `sh/require-tty-guard`.
3
+ *
4
+ * @internal
5
+ */
6
+ export interface RequireTtyGuardOptions {
7
+ /** Replaces the default interactive commands (`read`, `select`). */
8
+ readonly commands?: readonly string[];
9
+ }
10
+ /** Message ids of this rule. @internal */
11
+ export type RequireTtyGuardMessageId = 'unguardedInteractive' | 'wrapInTtyGuard';
12
+ /** Built-in rule `sh/require-tty-guard`. @internal */
13
+ export declare const requireTtyGuard: import("./define-shell-rule.js").ShellRule<[RequireTtyGuardOptions?], RequireTtyGuardMessageId>;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `sh/require-tty-guard`: a command that waits for input from the terminal
3
+ * (`read`, `select`, or any configured command) must be guarded by a
4
+ * check that stdin is a terminal, or must have its input supplied explicitly.
5
+ *
6
+ * An unguarded interactive prompt hangs forever when a script runs without a
7
+ * terminal (CI, cron, an agent's tool call), and in automation it reads
8
+ * whatever happens to be on stdin. The rule is **purely syntactic**: it never
9
+ * tracks data or control flow, so its notion of "guarded" is deliberately
10
+ * shallow and exactly this:
11
+ *
12
+ * - **Guarded by a test.** The command is lexically inside the `then`/`else`
13
+ * body of an `if` whose condition contains a TTY test, or it is the right
14
+ * side of an `&&` whose left side contains one. A TTY test is `[ -t 0 ]`,
15
+ * `test -t 0` or `[[ -t 0 ]]`; only file descriptor `0` counts. A guard
16
+ * anywhere else (an earlier statement, a calling function, a `||` exit, a
17
+ * `while` condition) is not seen.
18
+ * - **Input supplied explicitly.** The command, or any enclosing statement
19
+ * (so `while read …; done < file` is covered), redirects fd 0 from a file,
20
+ * here-document, or here-string; or it is the right side of a pipe; or it is
21
+ * a `read -u N` from a descriptor other than 0.
22
+ *
23
+ * `select` is parsed as a loop, not a command, so it is recognized from the
24
+ * loop node. Command names are read through `ShellSourceCode.getCommandName`,
25
+ * so a dynamic command word is never matched.
26
+ *
27
+ * The suggestion wraps the offending statement in `if [ -t 0 ]; then … fi`.
28
+ */
29
+ import { DashHdoc, Hdoc } from 'sh-ast/analyze';
30
+ import { createRule } from './create-rule.js';
31
+ import { lineBreakOf } from '../language/line-breaks.js';
32
+ /** Registry name of this rule. */
33
+ const RULE_NAME = 'require-tty-guard';
34
+ /** The interactive commands checked when `commands` is not configured. */
35
+ const DEFAULT_COMMANDS = ['read', 'select'];
36
+ /**
37
+ * Operator codes of the faithful syntax tree (`sh-ast`'s `BinCmdOperator` and
38
+ * `RedirOperator` values, which the lint view preserves as numbers).
39
+ */
40
+ const AND_STMT = 11;
41
+ const OR_STMT = 12;
42
+ const PIPE = 13;
43
+ const PIPE_ALL = 14;
44
+ const REDIRECT_INPUT_OPS = new Set([
45
+ 65, // `<`
46
+ 66, // `<>`
47
+ 67, // `<&`
48
+ 71, // `<<`
49
+ 72, // `<<-`
50
+ 73, // `<<<`
51
+ ]);
52
+ const DUP_INPUT = 67;
53
+ /** The `-t` unary test operator (`sh-ast`'s `TsFdTerm`). */
54
+ const TS_FD_TERM = 123;
55
+ /**
56
+ * Read options that consume an argument, so the next word is not a variable
57
+ * name or another option cluster.
58
+ */
59
+ const READ_ARG_FLAGS = new Set(['a', 'd', 'i', 'n', 'N', 'p', 't', 'u']);
60
+ /** The static text of `node` if it is a `Word` with a known value. */
61
+ function textOf(sourceCode, node) {
62
+ return node?.type === 'Word' ? sourceCode.resolveWordText(node) : null;
63
+ }
64
+ /**
65
+ * Whether `call` is a `[ … -t 0 … ]` or `test … -t 0 …` command. `-t 0` must
66
+ * appear as two adjacent static words.
67
+ */
68
+ function isCommandTtyTest(call, sourceCode) {
69
+ const name = sourceCode.getCommandName(call);
70
+ if (name !== '[' && name !== 'test')
71
+ return false;
72
+ const words = call.args.map((word) => textOf(sourceCode, word));
73
+ return words.some((word, i) => word === '-t' && words[i + 1] === '0');
74
+ }
75
+ /** Whether `test` is a `[[ -t 0 ]]` unary test. */
76
+ function isClauseTtyTest(test, sourceCode) {
77
+ return test.op === TS_FD_TERM && textOf(sourceCode, test.x) === '0';
78
+ }
79
+ /** Whether `inner` lies within `outer`, by source range. */
80
+ function within(inner, outer) {
81
+ return inner.range[0] >= outer[0] && inner.range[1] <= outer[1];
82
+ }
83
+ /** The source range covering a list of statements, or `null` if empty. */
84
+ function listRange(stmts) {
85
+ if (stmts.length === 0)
86
+ return null;
87
+ return [stmts[0].range[0], stmts[stmts.length - 1].range[1]];
88
+ }
89
+ /**
90
+ * Whether a redirect on a statement feeds fd 0 from an explicit source:
91
+ * `< file`, `0< file`, `<<`, `<<<`, `<> file`, or `<&N` for `N` other than 0.
92
+ */
93
+ function redirectsStdin(stmt, sourceCode) {
94
+ return stmt.redirs.some((redir) => {
95
+ if (!REDIRECT_INPUT_OPS.has(redir.op))
96
+ return false;
97
+ const fd = redir.n === undefined ? '0' : redir.n.value;
98
+ if (fd !== '0')
99
+ return false;
100
+ return redir.op !== DUP_INPUT || textOf(sourceCode, redir.word) !== '0';
101
+ });
102
+ }
103
+ /**
104
+ * Whether a `read` takes input from a descriptor other than 0 (`-u 3`,
105
+ * `-u3`, `-ru 3`). A non-static descriptor counts as non-zero, because the
106
+ * rule cannot show it is stdin. Option parsing stops at `--` or the first
107
+ * non-option word.
108
+ */
109
+ function readsFromOtherFd(call, sourceCode) {
110
+ const words = call.args.slice(1);
111
+ for (let i = 0; i < words.length; i += 1) {
112
+ const text = textOf(sourceCode, words[i]);
113
+ if (text === null || !text.startsWith('-') || text === '-' || text === '--')
114
+ return false;
115
+ for (let j = 1; j < text.length; j += 1) {
116
+ const flag = text.charAt(j);
117
+ if (!READ_ARG_FLAGS.has(flag))
118
+ continue;
119
+ const rest = text.slice(j + 1);
120
+ if (flag === 'u') {
121
+ const fd = rest === '' ? textOf(sourceCode, words[i + 1]) : rest;
122
+ return fd !== '0';
123
+ }
124
+ if (rest === '')
125
+ i += 1;
126
+ break;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * Whether the exit status of `stmt` steers control flow: it is a condition of
133
+ * `if`/`while`/`until`, or an operand of `&&`/`||`. Wrapping such a statement
134
+ * in `if … fi` would replace its status with the `if`'s (0 when the guard
135
+ * fails), changing which way the construct goes, so no suggestion is offered.
136
+ */
137
+ function isControlFlowOperand(stmt, sourceCode) {
138
+ const parent = sourceCode.getParent(stmt);
139
+ if (parent === undefined)
140
+ return false;
141
+ if (parent.type === 'BinaryCmd')
142
+ return parent.op === AND_STMT || parent.op === OR_STMT;
143
+ if (parent.type === 'IfClause' || parent.type === 'WhileClause') {
144
+ return [...parent.cond, ...parent.condlast].includes(stmt);
145
+ }
146
+ return false;
147
+ }
148
+ /** Redirection operators that open a here-document (`<<`, `<<-`), whose body follows the line. */
149
+ const HEREDOC_OPERATORS = new Set([Hdoc, DashHdoc]);
150
+ /**
151
+ * The text that replaces `stmtText` (a statement starting at `start`) with
152
+ * the statement inside `if [ -t 0 ]; then … fi`.
153
+ *
154
+ * A statement that starts its own line and spans several is wrapped on lines
155
+ * of its own. Anywhere else no line break goes before the statement: an
156
+ * earlier command on the same line may have opened a here-document, whose
157
+ * body begins at the next line break, so one inserted there would make the
158
+ * rest of the wrapper part of that body. A line break goes before `fi` only
159
+ * when the statement ends with a here-document of its own, whose terminator
160
+ * must stay alone on its line.
161
+ */
162
+ function wrapInGuard(text, start, stmtText, endsInHeredoc) {
163
+ const eol = lineBreakOf(text);
164
+ const startsLine = /^[ \t]*$/u.test(text.slice(text.lastIndexOf('\n', start - 1) + 1, start));
165
+ if (startsLine && stmtText.includes('\n'))
166
+ return `if [ -t 0 ]; then${eol}${stmtText}${eol}fi`;
167
+ return `if [ -t 0 ]; then ${stmtText}${endsInHeredoc ? eol : '; '}fi`;
168
+ }
169
+ /** Built-in rule `sh/require-tty-guard`. @internal */
170
+ export const requireTtyGuard = createRule({
171
+ name: RULE_NAME,
172
+ meta: {
173
+ type: 'suggestion',
174
+ docs: {
175
+ description: 'Require interactive commands such as `read` to be guarded by `[ -t 0 ]`',
176
+ },
177
+ hasSuggestions: true,
178
+ schema: [
179
+ {
180
+ type: 'object',
181
+ properties: {
182
+ commands: { type: 'array', items: { type: 'string', minLength: 1 } },
183
+ },
184
+ additionalProperties: false,
185
+ },
186
+ ],
187
+ messages: {
188
+ unguardedInteractive: '`{{command}}` waits for terminal input and hangs when stdin is not a terminal. Guard it with `[ -t 0 ]` or supply its input with a redirection.',
189
+ wrapInTtyGuard: 'Wrap the statement in `if [ -t 0 ]; then … fi`.',
190
+ },
191
+ },
192
+ create(context) {
193
+ const { sourceCode } = context;
194
+ const commands = new Set(context.options[0]?.commands ?? DEFAULT_COMMANDS);
195
+ // Interactive commands found in the file, checked once every guard is known.
196
+ const candidates = [];
197
+ const guards = [];
198
+ /** Whether any TTY test lies inside `range`. */
199
+ const guardIn = (range) => range !== null && guards.some((guard) => within(guard, range));
200
+ /** Whether `node` is a statement-list child inside a guarding `if`/`&&`. */
201
+ const isGuarded = (node) => {
202
+ let child = node;
203
+ for (let parent = sourceCode.getParent(child); parent; parent = sourceCode.getParent(child)) {
204
+ if (parent.type === 'IfClause') {
205
+ const cond = listRange([...parent.cond, ...parent.condlast]);
206
+ const inCond = cond !== null && within(child, cond);
207
+ if (!inCond && guardIn(cond))
208
+ return true;
209
+ }
210
+ else if (parent.type === 'BinaryCmd' && parent.op === AND_STMT) {
211
+ if (parent.y === child && parent.x !== undefined && guardIn(parent.x.range))
212
+ return true;
213
+ }
214
+ child = parent;
215
+ }
216
+ return false;
217
+ };
218
+ /** Whether stdin is supplied to `node` by a redirect or a pipe. */
219
+ const hasExplicitInput = (node) => {
220
+ let child = node;
221
+ for (let parent = sourceCode.getParent(child); parent; parent = sourceCode.getParent(child)) {
222
+ if (parent.type === 'Stmt' && redirectsStdin(parent, sourceCode))
223
+ return true;
224
+ // A coprocess body reads the coprocess's own input pipe, never the
225
+ // terminal, so a terminal guard is meaningless there.
226
+ if (parent.type === 'CoprocClause')
227
+ return true;
228
+ if (parent.type === 'BinaryCmd' &&
229
+ (parent.op === PIPE || parent.op === PIPE_ALL) &&
230
+ parent.y === child) {
231
+ return true;
232
+ }
233
+ child = parent;
234
+ }
235
+ return false;
236
+ };
237
+ return {
238
+ CallExpr(node) {
239
+ if (isCommandTtyTest(node, sourceCode))
240
+ guards.push(node);
241
+ const name = sourceCode.getCommandName(node);
242
+ if (name === null || !commands.has(name))
243
+ return;
244
+ if (name === 'read' && readsFromOtherFd(node, sourceCode))
245
+ return;
246
+ candidates.push({ node, name });
247
+ },
248
+ UnaryTest(node) {
249
+ if (isClauseTtyTest(node, sourceCode))
250
+ guards.push(node);
251
+ },
252
+ ForClause(node) {
253
+ if (node.select && commands.has('select')) {
254
+ candidates.push({ node, name: 'select' });
255
+ }
256
+ },
257
+ 'File:exit'() {
258
+ for (const { node, name } of candidates) {
259
+ if (hasExplicitInput(node) || isGuarded(node))
260
+ continue;
261
+ const stmt = sourceCode.getParent(node);
262
+ // A statement that runs in the background cannot be wrapped without
263
+ // changing what is backgrounded, so it gets no suggestion.
264
+ const wrappable = stmt?.type === 'Stmt' && !stmt.background && !isControlFlowOperand(stmt, sourceCode);
265
+ context.report({
266
+ node,
267
+ messageId: 'unguardedInteractive',
268
+ data: { command: name },
269
+ suggest: wrappable
270
+ ? [
271
+ {
272
+ messageId: 'wrapInTtyGuard',
273
+ fix: (fixer) => {
274
+ // A statement's own range also covers a trailing `;`, which
275
+ // must stay outside the wrapper, so the end is taken from
276
+ // the command and its redirections.
277
+ const end = Math.max(...(stmt.cmd === undefined ? [] : [stmt.cmd.range[1]]), ...stmt.redirs.map((redir) => redir.range[1]));
278
+ const body = wrapInGuard(sourceCode.text, stmt.range[0], sourceCode.text.slice(stmt.range[0], end), stmt.redirs.some((redir) => HEREDOC_OPERATORS.has(redir.op)));
279
+ return fixer.replaceTextRange([stmt.range[0], end], body);
280
+ },
281
+ },
282
+ ]
283
+ : [],
284
+ });
285
+ }
286
+ },
287
+ };
288
+ },
289
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Binding resolution for the declaration-convention extension point: the one
3
+ * place a rule finds out which {@link DeclarationConvention} the consumer has
4
+ * bound, if any.
5
+ *
6
+ * Every rule that consumes a convention calls
7
+ * {@link resolveDeclarationConvention} from its `create()`, so all of them
8
+ * agree on precedence, on what "not configured" means, and on how a
9
+ * misconfiguration is reported. The precedence is:
10
+ *
11
+ * 1. the rule's own `declarationConvention` option (its first options
12
+ * object), so one rule can be pointed at a different convention. ESLint
13
+ * flat config copies rule options with `structuredClone`, so a convention
14
+ * whose methods are own properties is rejected there by ESLint itself
15
+ * ("could not be cloned"), and a class instance arrives with its methods
16
+ * stripped and fails the check below. In practice consumers bind through
17
+ * settings;
18
+ * 2. the shared `settings.sh.declarationConvention`, bound once for every
19
+ * consuming rule;
20
+ * 3. otherwise `null`: no convention is bound and the consuming rule is
21
+ * inert — it reports nothing. Shipping a default would smuggle one
22
+ * project's declaration policy into every project.
23
+ *
24
+ * Only an absent (`undefined`) value falls through to the next source. Any
25
+ * other value is the consumer's explicit choice and must be a convention; if
26
+ * it is not, resolution throws an error that names the offending setting, so
27
+ * the misconfiguration surfaces as a readable configuration message rather
28
+ * than a `TypeError` from deep inside a rule.
29
+ *
30
+ * The resolver is public so a rule pack can write its own consumer of the
31
+ * bound convention and get exactly the precedence, validation and error text
32
+ * the built-in consumers (`sh/no-undeclared-commands`,
33
+ * `sh/no-unused-declarations`) use, instead of re-implementing them.
34
+ */
35
+ import type { SettingsConfig } from '@eslint/core';
36
+ import type { DeclarationConvention } from '../declaration-conventions/declaration-convention.js';
37
+ /**
38
+ * The slice of an ESLint rule context that binding resolution reads: the rule
39
+ * id (for error messages), its configured options, and the shared settings.
40
+ * Any rule `context` satisfies it, so a rule passes its own `context`.
41
+ *
42
+ * @public
43
+ */
44
+ export interface DeclarationConventionBindingContext {
45
+ /** The rule id as configured (`pack/my-rule`), named in an option-binding error. */
46
+ readonly id: string;
47
+ /** The rule's configured options; the first may carry `declarationConvention`. */
48
+ readonly options: readonly unknown[];
49
+ /** The shared settings; `settings.sh.declarationConvention` is the usual binding. */
50
+ readonly settings: SettingsConfig;
51
+ }
52
+ /**
53
+ * Resolves the declaration convention bound for the rule running in
54
+ * `context`: the rule's `declarationConvention` option, else
55
+ * `settings.sh.declarationConvention`, else `null` (the rule is inert).
56
+ *
57
+ * Throws an `Error` naming the offending setting when the configured value is
58
+ * present but does not implement `declaredCommands` (or has a non-function
59
+ * `removeDeclaration`). The message names where the value came from and what
60
+ * it was, for example one beginning
61
+ * `Invalid settings.sh.declarationConvention: expected a declaration convention`
62
+ * and ending `but got string "jq".` Call it from `create()`, so the error
63
+ * surfaces as ESLint's
64
+ * configuration message for the file being linted.
65
+ *
66
+ * A rule that receives `null` should register no listeners: with no
67
+ * convention bound, a convention consumer has nothing to check.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import {
72
+ * defineShellRule,
73
+ * readDeclaredCommands,
74
+ * resolveDeclarationConvention,
75
+ * } from '@eslint-sh/eslint-plugin';
76
+ *
77
+ * export const maxDeclaredCommands = defineShellRule({
78
+ * meta: { schema: [], messages: { tooMany: 'Declare at most 5 commands.' } },
79
+ * create(context) {
80
+ * const convention = resolveDeclarationConvention(context);
81
+ * if (convention === null) return {};
82
+ * return {
83
+ * File(node) {
84
+ * // Validated declarations for the file `context` is linting.
85
+ * if (readDeclaredCommands(convention, context).length > 5) {
86
+ * context.report({ node, messageId: 'tooMany' });
87
+ * }
88
+ * },
89
+ * };
90
+ * },
91
+ * });
92
+ * ```
93
+ *
94
+ * @public
95
+ */
96
+ export declare function resolveDeclarationConvention(context: DeclarationConventionBindingContext): DeclarationConvention | null;