@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,123 @@
1
+ /**
2
+ * `sh/compat/no-negative-array-index`: reports negative indexed-array
3
+ * subscripts written as a literal (`${arr[-1]}`, `arr[-2]=x`, `${#arr[-1]}`)
4
+ * in files whose target shell lacks them, that is, Bash older than 4.3
5
+ * (macOS's stock `/bin/bash` is 3.2) and POSIX sh.
6
+ *
7
+ * Bash 3.2 evaluates `-1` as the arithmetic value minus one and fails with
8
+ * "bad array subscript", so the read yields nothing and the assignment is
9
+ * refused. The message suggests the portable alternative: count from the
10
+ * array length, `${arr[$((${#arr[@]}-1))]}`.
11
+ *
12
+ * The suggestion rewrites exactly a bare `${NAME[-N]}` read to
13
+ * `${NAME[$((${#NAME[@]}-N))]}`. Negative subscripts count back from the
14
+ * highest assigned index plus one, while `${#NAME[@]}` counts set elements,
15
+ * so the two agree only for arrays without holes; the suggestion message says
16
+ * so. Assignments and other expansion forms are reported without a suggestion.
17
+ *
18
+ * What is read: an `Index` that is a unary minus applied to a static decimal
19
+ * integer, on a `ParamExp` or an `Assign`. A computed subscript such as
20
+ * `-$n` is not a literal and is not reported, and neither is `-0`, which is
21
+ * zero. A `ParamExp` or `Assign` subscript is read from the syntax tree, so
22
+ * its operand can be any arithmetic node (`-(1)` holds a parenthesized one);
23
+ * only a plain word is resolved.
24
+ *
25
+ * Unsetting an element (`unset arr[-1]`, quoted or not) is reported from the
26
+ * `unset` command's operand text, since the parser keeps that operand as an
27
+ * ordinary word; option words (`-v`) never match an element name.
28
+ */
29
+ import { Minus } from 'sh-ast';
30
+ import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
31
+ import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
32
+ import { createCompatRule } from './create-compat-rule.js';
33
+ /** A bare negative-subscript read, the only shape the suggestion rewrites. */
34
+ const BARE_READ = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\[\s*-\s*(\d+)\s*\]\}$/u;
35
+ /**
36
+ * The wrappers followed to find an `unset` command: `sh-ast/analyze`'s
37
+ * defaults (which include `command`) plus Bash's `builtin`.
38
+ */
39
+ const TRANSPARENT_WRAPPERS = [
40
+ ...DEFAULT_TRANSPARENT_WRAPPERS,
41
+ BUILTIN_WRAPPER,
42
+ ];
43
+ /** An `unset` operand naming an element by a literal negative subscript. */
44
+ const UNSET_NEGATIVE = /^[A-Za-z_][A-Za-z0-9_]*\[\s*-\s*\d+\s*\]$/u;
45
+ /**
46
+ * The `sh/compat/no-negative-array-index` rule module.
47
+ *
48
+ * @internal
49
+ */
50
+ export const noNegativeArrayIndex = createCompatRule({
51
+ name: 'compat/no-negative-array-index',
52
+ feature: 'negativeArrayIndex',
53
+ meta: {
54
+ type: 'problem',
55
+ hasSuggestions: true,
56
+ docs: {
57
+ description: 'Disallow negative array subscripts (`${arr[-1]}`) when the target shell lacks them',
58
+ },
59
+ messages: {
60
+ negativeIndex: 'Negative array subscripts need {{required}}, but this file targets {{target}}. Count from the length instead, for example `${arr[$((${#arr[@]}-1))]}`.',
61
+ useArrayLength: 'Replace with `${{{name}}[$((${#{{name}}[@]}-{{n}}))]}` (the two differ only for an array with unset elements).',
62
+ },
63
+ },
64
+ create(context, gate) {
65
+ const { sourceCode } = context;
66
+ /** Whether `index` is a unary minus on a static decimal integer. */
67
+ function isNegativeLiteral(index) {
68
+ if (index?.type !== 'UnaryArithm' || index.op !== Minus)
69
+ return false;
70
+ const operand = index.x;
71
+ // The operand may be any arithmetic node (`-(1)` holds a ParenArithm);
72
+ // only a Word can be resolved, and `-0` is zero, not a negative index.
73
+ if (operand?.type !== 'Word')
74
+ return false;
75
+ const digits = sourceCode.resolveWordText(operand) ?? '';
76
+ return /^\d+$/u.test(digits) && /[1-9]/u.test(digits);
77
+ }
78
+ return {
79
+ CallExpr(node) {
80
+ const invoked = sourceCode.resolveInvokedCommand(node, {
81
+ transparentWrappers: TRANSPARENT_WRAPPERS,
82
+ });
83
+ if (invoked?.effective.static !== true || invoked.effective.text !== 'unset')
84
+ return;
85
+ // `chainIndices` index `node.args` directly, so slice before filtering.
86
+ for (const word of node.args.slice((invoked.chainIndices.at(-1) ?? 0) + 1)) {
87
+ // An unquoted `a[-1]` holds a glob bracket, so it is not "static"; its
88
+ // written text is what names the element.
89
+ const text = sourceCode.resolveWordText(word) ?? sourceCode.getText(word);
90
+ if (text === '--')
91
+ continue;
92
+ if (UNSET_NEGATIVE.test(text) && /[1-9]/u.test(text)) {
93
+ context.report({ node: word, messageId: 'negativeIndex', data: { ...gate } });
94
+ }
95
+ }
96
+ },
97
+ ParamExp(node) {
98
+ if (!isNegativeLiteral(node.index))
99
+ return;
100
+ const match = BARE_READ.exec(sourceCode.getText(node));
101
+ context.report({
102
+ node,
103
+ messageId: 'negativeIndex',
104
+ data: { ...gate },
105
+ suggest: match === null
106
+ ? []
107
+ : [
108
+ {
109
+ messageId: 'useArrayLength',
110
+ data: { name: match[1], n: match[2] },
111
+ fix: (fixer) => fixer.replaceText(node, `\${${match[1]}[$((\${#${match[1]}[@]}-${match[2]}))]}`),
112
+ },
113
+ ],
114
+ });
115
+ },
116
+ Assign(node) {
117
+ if (!isNegativeLiteral(node.index))
118
+ return;
119
+ context.report({ node, messageId: 'negativeIndex', data: { ...gate } });
120
+ },
121
+ };
122
+ },
123
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `emptyArray` for the report and
3
+ * `guardExpansion` for its suggestion.
4
+ *
5
+ * @internal
6
+ */
7
+ export type NoNounsetEmptyArrayMessageId = 'emptyArray' | 'guardExpansion';
8
+ /**
9
+ * The `sh/compat/no-nounset-empty-array` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const noNounsetEmptyArray: import("../define-shell-rule.js").ShellRule<[], NoNounsetEmptyArrayMessageId>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `sh/compat/no-nounset-empty-array`: reports whole-array expansions
3
+ * (`"${a[@]}"`, `${a[*]}`) in a file that turns on `set -u`, when its target
4
+ * shell is Bash older than 4.4 (macOS's stock `/bin/bash` is 3.2) or POSIX
5
+ * sh.
6
+ *
7
+ * The syntax is in every Bash, but through 4.3 expanding an empty array
8
+ * while `nounset` is on aborts the script with "unbound variable". Bash 4.4
9
+ * is the first release where it expands to nothing. This is the classic
10
+ * "works on Linux CI, breaks on a Mac" failure for a script that passes an
11
+ * optional, possibly empty, argument array under `set -euo pipefail`.
12
+ *
13
+ * The suggestion rewrites a word that is exactly `"${a[@]}"` to
14
+ * `${a[@]+"${a[@]}"}`, which expands to nothing for an empty array and to the
15
+ * same words otherwise, and a word that is exactly `"${a[*]}"` to
16
+ * `"${a[*]-}"`, which keeps the one (empty) word `"${a[*]}"` gives for an
17
+ * empty array. Other spellings (unquoted, or inside a longer word) are
18
+ * reported without one. POSIX sh has no arrays at all; under
19
+ * `dialect: "posix"` the parser rejects array syntax, so the rule never sees
20
+ * one there.
21
+ *
22
+ * What is read:
23
+ *
24
+ * - whether the file turns `nounset` on anywhere: a `set` command that
25
+ * enables it (`set -u`, `set -eu`, `set -o nounset`), or a shebang that
26
+ * passes the flag (`#!/bin/bash -eu`). Where in the file that happens is
27
+ * not considered, since a function defined before `set -u` can run after
28
+ * it;
29
+ * - every `${name[@]}` and `${name[*]}` with no operator, except inside
30
+ * `${name[@]+…}` or `${name[@]:+…}` of the same array (the guard this rule
31
+ * suggests), whose word Bash expands only when the array is set. `${#a[@]}`,
32
+ * `${!a[@]}` and `${a[@]-default}` work on an empty array under `set -u` in
33
+ * every Bash, so they are not reported. The slice `${a[@]:1}` and the
34
+ * replacement `${a[@]/x/y}` were observed to work on Bash 3.2 to 4.1 and
35
+ * fail only on 4.2 and 4.3, which a minimum-version gate cannot express, so
36
+ * they are not reported either.
37
+ */
38
+ import { AlternateUnset, AlternateUnsetOrNull } from 'sh-ast';
39
+ import { createCompatRule } from './create-compat-rule.js';
40
+ import { parseSetArguments, shebangOptionChanges } from '../set-options.js';
41
+ /** A shell variable name. */
42
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
43
+ /**
44
+ * The guarded spelling of `"${name[subscript]}"`: `${name[@]+"${name[@]}"}`
45
+ * for `@` (no words for an empty array, the same words otherwise) and
46
+ * `"${name[*]-}"` for `*` (one empty word for an empty array, as Bash 4.4
47
+ * gives, and the same joined word otherwise).
48
+ */
49
+ function guardedExpansion(name, subscript) {
50
+ return subscript === '@' ? `\${${name}[@]+"\${${name}[@]}"}` : `"\${${name}[*]-}"`;
51
+ }
52
+ /** Whether `changes` turn `nounset` on. */
53
+ function enablesNounset(changes) {
54
+ return changes.some((change) => change.option === 'nounset' && change.enable);
55
+ }
56
+ /**
57
+ * The `sh/compat/no-nounset-empty-array` rule module.
58
+ *
59
+ * @internal
60
+ */
61
+ export const noNounsetEmptyArray = createCompatRule({
62
+ name: 'compat/no-nounset-empty-array',
63
+ feature: 'nounsetEmptyArray',
64
+ meta: {
65
+ type: 'problem',
66
+ hasSuggestions: true,
67
+ docs: {
68
+ description: 'Disallow whole-array expansions under `set -u` when the target shell aborts on an empty array',
69
+ },
70
+ messages: {
71
+ emptyArray: 'If this array is empty, `${{{name}}[{{subscript}}]}` under `set -u` needs {{required}}, but this file targets {{target}}, where it aborts with "unbound variable". Guard it, for example `{{example}}`.',
72
+ guardExpansion: 'Replace with `{{replacement}}`.',
73
+ },
74
+ },
75
+ create(context, gate) {
76
+ const { sourceCode } = context;
77
+ let nounset = enablesNounset(shebangOptionChanges(sourceCode.text));
78
+ const candidates = [];
79
+ /** The `@` or `*` of a whole-array subscript, or `undefined`. */
80
+ function wholeArraySubscript(index) {
81
+ if (index?.type !== 'Word')
82
+ return undefined;
83
+ const text = sourceCode.resolveWordText(index) ?? sourceCode.getText(index);
84
+ return text === '@' || text === '*' ? text : undefined;
85
+ }
86
+ /**
87
+ * Whether `node` sits inside `${name[…]+…}` or `${name[…]:+…}` of the same
88
+ * array, as in the guard `${a[@]+"${a[@]}"}`: Bash expands that inner
89
+ * word only when the array is set, so it cannot abort.
90
+ */
91
+ function insideOwnGuard(node, name) {
92
+ for (let up = sourceCode.getParent(node); up !== undefined; up = sourceCode.getParent(up)) {
93
+ if (up.type === 'ParamExp' &&
94
+ up.param?.value === name &&
95
+ (up.exp?.op === AlternateUnset || up.exp?.op === AlternateUnsetOrNull)) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * The quoted expansion `node` is when it is the whole of a word that is
103
+ * exactly `"${name[@]}"` or `"${name[*]}"`, or `undefined`.
104
+ */
105
+ function wholeQuotedWord(node) {
106
+ const quoted = sourceCode.getParent(node);
107
+ if (quoted?.type !== 'DblQuoted' || quoted.parts.length !== 1)
108
+ return undefined;
109
+ const word = sourceCode.getParent(quoted);
110
+ return word?.type === 'Word' && word.parts.length === 1 ? quoted : undefined;
111
+ }
112
+ return {
113
+ CallExpr(node) {
114
+ if (sourceCode.getCommandName(node) !== 'set')
115
+ return;
116
+ const args = node.args.slice(1).map((word) => sourceCode.resolveWordText(word));
117
+ if (enablesNounset(parseSetArguments(args).changes))
118
+ nounset = true;
119
+ },
120
+ ParamExp(node) {
121
+ const name = node.param?.value;
122
+ if (name === undefined || !IDENTIFIER.test(name))
123
+ return;
124
+ if (node.exp !== undefined || node.slice !== undefined || node.repl !== undefined)
125
+ return;
126
+ if (node.length || node.excl || node.width || node.isset || node.modifiers.length > 0) {
127
+ return;
128
+ }
129
+ const subscript = wholeArraySubscript(node.index);
130
+ if (subscript === undefined || insideOwnGuard(node, name))
131
+ return;
132
+ candidates.push({ node, name, subscript });
133
+ },
134
+ 'File:exit'() {
135
+ if (!nounset)
136
+ return;
137
+ for (const { node, name, subscript } of candidates) {
138
+ const replacement = guardedExpansion(name, subscript);
139
+ const quoted = wholeQuotedWord(node);
140
+ context.report({
141
+ node,
142
+ messageId: 'emptyArray',
143
+ data: { ...gate, name, subscript, example: replacement },
144
+ suggest: quoted === undefined
145
+ ? []
146
+ : [
147
+ {
148
+ messageId: 'guardExpansion',
149
+ data: { replacement },
150
+ fix: (fixer) => fixer.replaceText(quoted, replacement),
151
+ },
152
+ ],
153
+ });
154
+ }
155
+ },
156
+ };
157
+ },
158
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `transformation` names an alternative,
3
+ * `transformationNoAlternative` has none to offer.
4
+ *
5
+ * @internal
6
+ */
7
+ export type NoParameterTransformationMessageId = 'transformation' | 'transformationNoAlternative';
8
+ /**
9
+ * The `sh/compat/no-parameter-transformation` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const noParameterTransformation: import("../define-shell-rule.js").ShellRule<[], NoParameterTransformationMessageId>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `sh/compat/no-parameter-transformation`: reports the parameter
3
+ * transformations `${v@op}` in files whose target shell lacks the operator.
4
+ * The minimum Bash depends on the operator:
5
+ *
6
+ * - `@Q`, `@E`, `@P`, `@A`, `@a`: Bash 4.4;
7
+ * - `@U`, `@u`, `@L` and `@K`: Bash 5.1;
8
+ * - `@k`: Bash 5.2.
9
+ *
10
+ * POSIX sh has none of them. An older Bash fails with "bad substitution"
11
+ * when the word is expanded. The message names a portable alternative where
12
+ * one exists for the target: `printf '%q'` for `@Q` (it quotes differently,
13
+ * but `eval` reads it back as the same value), `printf '%b'` for `@E`,
14
+ * `declare -p` for `@A`, and for the case operators the Bash 4.0 case
15
+ * expansions (`${v^^}`, `${v^}`, `${v,,}`) on a Bash that has them, or `tr`
16
+ * otherwise. `printf '%q'` and `declare -p` are not POSIX, so a POSIX target
17
+ * is not offered them.
18
+ *
19
+ * What is read: a `ParamExp` whose expansion operator is `@` followed by one
20
+ * of the letters above. The report is on the whole expansion.
21
+ */
22
+ import { OtherParamOps } from 'sh-ast';
23
+ import { createMultiFeatureCompatRule } from './create-compat-rule.js';
24
+ import { TO_LOWER, TO_UPPER, trExample } from './no-case-modification.js';
25
+ import { isFeatureAvailable } from './version-gates.js';
26
+ /** Each operator letter's gate-table row. */
27
+ const OPERATOR_FEATURES = new Map([
28
+ ['Q', 'parameterTransformation'],
29
+ ['E', 'parameterTransformation'],
30
+ ['P', 'parameterTransformation'],
31
+ ['A', 'parameterTransformation'],
32
+ ['a', 'parameterTransformation'],
33
+ ['U', 'parameterTransformationCase'],
34
+ ['u', 'parameterTransformationCase'],
35
+ ['L', 'parameterTransformationCase'],
36
+ ['K', 'parameterTransformationQuotedKeys'],
37
+ ['k', 'parameterTransformationKeys'],
38
+ ]);
39
+ /** The case operators and their equivalents. */
40
+ const CASE_EQUIVALENTS = new Map([
41
+ ['U', { expansion: '^^', upper: true, all: true }],
42
+ ['u', { expansion: '^', upper: true, all: false }],
43
+ ['L', { expansion: ',,', upper: false, all: true }],
44
+ ]);
45
+ /**
46
+ * The `sh/compat/no-parameter-transformation` rule module.
47
+ *
48
+ * @internal
49
+ */
50
+ export const noParameterTransformation = createMultiFeatureCompatRule({
51
+ name: 'compat/no-parameter-transformation',
52
+ features: [...new Set(OPERATOR_FEATURES.values())],
53
+ meta: {
54
+ type: 'problem',
55
+ docs: {
56
+ description: 'Disallow parameter transformations (`${v@Q}`, `${v@U}` and the others) when the target shell lacks the operator',
57
+ },
58
+ messages: {
59
+ transformation: '`${…@{{operator}}}` needs {{required}}, but this file targets {{target}}. Use {{alternative}} instead.',
60
+ transformationNoAlternative: '`${…@{{operator}}}` needs {{required}}, but this file targets {{target}}, which has no equivalent expansion; restructure the code that needs it.',
61
+ },
62
+ },
63
+ create(context, gates) {
64
+ const { sourceCode, languageOptions } = context;
65
+ const posix = sourceCode.dialect === 'posix';
66
+ const caseExpansions = isFeatureAvailable('caseModification', languageOptions);
67
+ /** The alternative shown for `operator` on `name`, or `undefined`. */
68
+ function alternative(operator, name) {
69
+ const value = `"$${name}"`;
70
+ if (operator === 'Q') {
71
+ return posix
72
+ ? undefined
73
+ : `\`printf '%q' ${value}\` (it quotes differently, but \`eval\` reads it back as the same value)`;
74
+ }
75
+ if (operator === 'E')
76
+ return `\`printf '%b' ${value}\``;
77
+ if (operator === 'A') {
78
+ return posix
79
+ ? undefined
80
+ : `\`declare -p ${name}\` (it prints the declaration; capture it and \`eval\` it to re-create the variable)`;
81
+ }
82
+ const equivalent = CASE_EQUIVALENTS.get(operator);
83
+ if (equivalent === undefined)
84
+ return undefined;
85
+ if (caseExpansions)
86
+ return `\`"\${${name}${equivalent.expansion}}"\``;
87
+ const example = trExample(name, {
88
+ operator: equivalent.expansion,
89
+ all: equivalent.all,
90
+ tr: equivalent.upper ? TO_UPPER : TO_LOWER,
91
+ });
92
+ return `\`tr\`, for example ${example}`;
93
+ }
94
+ return {
95
+ ParamExp(node) {
96
+ const { exp } = node;
97
+ if (exp?.op !== OtherParamOps || exp.word === undefined)
98
+ return;
99
+ const operator = sourceCode.resolveWordText(exp.word);
100
+ if (operator === null)
101
+ return;
102
+ const feature = OPERATOR_FEATURES.get(operator);
103
+ const gate = feature === undefined ? undefined : gates.get(feature);
104
+ if (gate === undefined)
105
+ return;
106
+ const advice = alternative(operator, node.param?.value ?? 'v');
107
+ if (advice === undefined) {
108
+ context.report({
109
+ node,
110
+ messageId: 'transformationNoAlternative',
111
+ data: { ...gate, operator },
112
+ });
113
+ return;
114
+ }
115
+ context.report({
116
+ node,
117
+ messageId: 'transformation',
118
+ data: { ...gate, operator, alternative: advice },
119
+ });
120
+ },
121
+ };
122
+ },
123
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `pipeAll` for the report and
3
+ * `useStderrRedirect` for its suggestion.
4
+ *
5
+ * @internal
6
+ */
7
+ export type NoPipeAllMessageId = 'pipeAll' | 'useStderrRedirect';
8
+ /**
9
+ * The `sh/compat/no-pipe-all` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const noPipeAll: import("../define-shell-rule.js").ShellRule<[], NoPipeAllMessageId>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `sh/compat/no-pipe-all`: reports the `|&` pipe, shorthand for `2>&1 |`, in
3
+ * files whose target shell lacks it, that is, Bash older than 4.0 (macOS's
4
+ * stock `/bin/bash` is 3.2) and POSIX sh.
5
+ *
6
+ * Bash 3.2 rejects `|&` as a syntax error, so the whole script fails to run.
7
+ * The portable spelling is `cmd 2>&1 | other`. The Bash manual defines `|&`
8
+ * as that implicit `2>&1`, applied after any redirections the left-hand
9
+ * command writes, so writing `2>&1` just before the `|` is exactly
10
+ * equivalent. The rule offers that rewrite as a suggestion.
11
+ *
12
+ * What is read: a pipeline (`BinaryCmd`) whose operator is `|&`. A plain `|`,
13
+ * `||`, `&&`, a background `&`, and any of these inside quotes are other
14
+ * syntax and are not reported. The report is on the `|&` operator.
15
+ */
16
+ import { PipeAll } from 'sh-ast';
17
+ import { createCompatRule } from './create-compat-rule.js';
18
+ import { operatorRange } from './operator-range.js';
19
+ /**
20
+ * The `sh/compat/no-pipe-all` rule module.
21
+ *
22
+ * @internal
23
+ */
24
+ export const noPipeAll = createCompatRule({
25
+ name: 'compat/no-pipe-all',
26
+ feature: 'pipeAll',
27
+ meta: {
28
+ type: 'problem',
29
+ hasSuggestions: true,
30
+ docs: {
31
+ description: 'Disallow the `|&` pipe when the target shell lacks it',
32
+ },
33
+ messages: {
34
+ pipeAll: '`|&` needs {{required}}, but this file targets {{target}}. Write `cmd 2>&1 | other` instead.',
35
+ useStderrRedirect: 'Replace `|&` with `2>&1 |`.',
36
+ },
37
+ },
38
+ create(context, gate) {
39
+ const { sourceCode } = context;
40
+ return {
41
+ BinaryCmd(node) {
42
+ if (node.op !== PipeAll)
43
+ return;
44
+ const range = operatorRange(sourceCode, node, '|&');
45
+ if (range === undefined) {
46
+ context.report({ node, messageId: 'pipeAll', data: { ...gate } });
47
+ return;
48
+ }
49
+ const [start, end] = range;
50
+ // `cmd|&other` has no space before the operator, and `2>&1` must not
51
+ // join the command's last word.
52
+ const before = sourceCode.text[start - 1];
53
+ const separator = before === ' ' || before === '\t' ? '' : ' ';
54
+ context.report({
55
+ loc: { start: sourceCode.getLocFromIndex(start), end: sourceCode.getLocFromIndex(end) },
56
+ messageId: 'pipeAll',
57
+ data: { ...gate },
58
+ suggest: [
59
+ {
60
+ messageId: 'useStderrRedirect',
61
+ fix: (fixer) => fixer.replaceTextRange(range, `${separator}2>&1 |`),
62
+ },
63
+ ],
64
+ });
65
+ },
66
+ };
67
+ },
68
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Message ids this rule reports with.
3
+ *
4
+ * @internal
5
+ */
6
+ export type NoPrintfTimeFormatMessageId = 'printfTime';
7
+ /**
8
+ * The `sh/compat/no-printf-time-format` rule module.
9
+ *
10
+ * @internal
11
+ */
12
+ export declare const noPrintfTimeFormat: import("../define-shell-rule.js").ShellRule<[], "printfTime">;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `sh/compat/no-printf-time-format`: reports `printf`'s `%(fmt)T` date
3
+ * format in files whose target shell lacks it, that is, Bash older than 4.2
4
+ * (macOS's stock `/bin/bash` is 3.2) and POSIX sh.
5
+ *
6
+ * Bash 4.1 rejects the directive with "invalid format character", so
7
+ * nothing is printed. The message suggests the portable alternative for the
8
+ * current time: `date '+fmt'`, which takes the same `strftime` format.
9
+ *
10
+ * What is read: a `printf` command (after transparent wrappers such as
11
+ * `builtin`) whose format argument, after an optional `-v name` and `--`, is
12
+ * a static word containing a `%(…)T` directive. The format is scanned as
13
+ * `printf` reads it: `%%` is a literal percent sign, and a directive may
14
+ * carry flags, a width and a precision before the `(`. A format that is not
15
+ * static cannot be read, and is not reported. The report is on the format
16
+ * word.
17
+ */
18
+ import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
19
+ import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
20
+ import { createCompatRule } from './create-compat-rule.js';
21
+ /**
22
+ * The wrappers followed to find the `printf` command: `sh-ast/analyze`'s
23
+ * defaults (which include `command`) plus Bash's `builtin`.
24
+ */
25
+ const TRANSPARENT_WRAPPERS = [
26
+ ...DEFAULT_TRANSPARENT_WRAPPERS,
27
+ BUILTIN_WRAPPER,
28
+ ];
29
+ /** A conversion's flags, width and precision, as `printf` accepts them. */
30
+ const DIRECTIVE_PREFIX = /^[-+ #0']*(?:\d+|\*)?(?:\.(?:\d+|\*)?)?/u;
31
+ /**
32
+ * The `strftime` format of the first `%(fmt)T` directive in a `printf`
33
+ * format string, or `undefined` when it has none.
34
+ */
35
+ function timeFormatIn(format) {
36
+ for (let index = 0; index < format.length; index++) {
37
+ if (format[index] !== '%')
38
+ continue;
39
+ if (format[index + 1] === '%') {
40
+ index++;
41
+ continue;
42
+ }
43
+ const rest = format.slice(index + 1);
44
+ const prefix = DIRECTIVE_PREFIX.exec(rest)?.[0] ?? '';
45
+ const afterPrefix = rest.slice(prefix.length);
46
+ if (!afterPrefix.startsWith('('))
47
+ continue;
48
+ const close = afterPrefix.indexOf(')');
49
+ if (close !== -1 && afterPrefix[close + 1] === 'T')
50
+ return afterPrefix.slice(1, close);
51
+ }
52
+ return undefined;
53
+ }
54
+ /**
55
+ * The `sh/compat/no-printf-time-format` rule module.
56
+ *
57
+ * @internal
58
+ */
59
+ export const noPrintfTimeFormat = createCompatRule({
60
+ name: 'compat/no-printf-time-format',
61
+ feature: 'printfTimeFormat',
62
+ meta: {
63
+ type: 'problem',
64
+ docs: {
65
+ description: "Disallow `printf`'s `%(fmt)T` date format when the target shell lacks it",
66
+ },
67
+ messages: {
68
+ printfTime: "`printf '%({{format}})T'` needs {{required}}, but this file targets {{target}}. For the current time, run `date '+{{format}}'` instead.",
69
+ },
70
+ },
71
+ create(context, gate) {
72
+ const { sourceCode } = context;
73
+ return {
74
+ CallExpr(node) {
75
+ const invoked = sourceCode.resolveInvokedCommand(node, {
76
+ transparentWrappers: TRANSPARENT_WRAPPERS,
77
+ });
78
+ if (invoked?.effective.static !== true || invoked.effective.text !== 'printf')
79
+ return;
80
+ // `chainIndices` index `node.args` directly, so slice before filtering.
81
+ const words = node.args.slice((invoked.chainIndices.at(-1) ?? 0) + 1);
82
+ let index = 0;
83
+ const first = words.at(0);
84
+ if (first !== undefined && sourceCode.resolveWordText(first) === '-v')
85
+ index = 2;
86
+ if (index < words.length && sourceCode.resolveWordText(words[index]) === '--')
87
+ index++;
88
+ const formatWord = words.at(index);
89
+ if (formatWord === undefined)
90
+ return;
91
+ const text = sourceCode.resolveWordText(formatWord);
92
+ const format = text === null ? undefined : timeFormatIn(text);
93
+ if (format === undefined)
94
+ return;
95
+ context.report({ node: formatWord, messageId: 'printfTime', data: { ...gate, format } });
96
+ },
97
+ };
98
+ },
99
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `source` for the report and `useDot` for
3
+ * its suggestion.
4
+ *
5
+ * @internal
6
+ */
7
+ export type NoSourceMessageId = 'source' | 'useDot';
8
+ /**
9
+ * The `sh/compat/no-source` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const noSource: import("../define-shell-rule.js").ShellRule<[], NoSourceMessageId>;