@eslint-sh/eslint-plugin 0.4.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,122 @@
1
+ /**
2
+ * `sh/compat/no-mapfile`: reports the `mapfile` builtin and its `readarray`
3
+ * synonym in files whose target shell lacks them, that is, Bash older than
4
+ * 4.0 (macOS's stock `/bin/bash` is 3.2) and POSIX sh. Under Bash 3.2 the
5
+ * name is looked up as an external command, which does not exist, so the
6
+ * array is never filled.
7
+ *
8
+ * The command is recognized as the effective command after following
9
+ * transparent wrappers (`command mapfile …`, `builtin readarray …`), through
10
+ * `ShellSourceCode.resolveInvokedCommand`.
11
+ *
12
+ * The suggestion rewrites the one shape whose array contents a loop
13
+ * reproduces exactly, `mapfile -t [NAME]` written directly (no wrapper, no leading
14
+ * `VAR=value` assignment) in a Bash-family file: it becomes a brace group
15
+ * that empties the array and appends each line read by
16
+ * `while IFS= read -r … || [ -n "$REPLY" ]`. The `|| [ -n "$REPLY" ]` keeps a
17
+ * final line that has no trailing newline, as `mapfile -t` does. Reading with
18
+ * no variable name keeps every byte of the line, but it assigns `REPLY`:
19
+ * unlike `mapfile`, the loop leaves `REPLY` set to the empty string, whatever
20
+ * it held (or whether it was set) before. `REPLY` is `read`'s own default
21
+ * variable, so this is the narrowest side effect a read loop can have; the
22
+ * alternatives (a named loop variable, or saving and restoring `REPLY` in
23
+ * another variable) overwrite a name the script is likelier to use. The
24
+ * suggestion message states the tradeoff. The brace group keeps the command a single
25
+ * unit, so a redirection (`< file`) or pipeline (`cmd | mapfile -t a`) still
26
+ * feeds the loop. Other options (`-d`, `-n`, `-O`, `-s`, `-u`, `-C`, or no
27
+ * `-t`, which keeps each newline) and POSIX files, which have no arrays at
28
+ * all, get the report without a suggestion.
29
+ */
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
+ /** The builtin's two names. */
34
+ const MAPFILE_COMMANDS = new Set(['mapfile', 'readarray']);
35
+ /** The array `mapfile` fills when no name is given. */
36
+ const DEFAULT_ARRAY = 'MAPFILE';
37
+ /** A shell variable name, the only operand shape the suggestion rewrites. */
38
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
39
+ /**
40
+ * The wrappers followed to find the command an invocation runs:
41
+ * `sh-ast/analyze`'s defaults (which include `command`) plus Bash's
42
+ * `builtin`.
43
+ */
44
+ const TRANSPARENT_WRAPPERS = [
45
+ ...DEFAULT_TRANSPARENT_WRAPPERS,
46
+ BUILTIN_WRAPPER,
47
+ ];
48
+ /**
49
+ * The loop that fills `array` exactly as `mapfile -t array` does, wrapped in
50
+ * a brace group so redirections and pipelines still apply to all of it.
51
+ */
52
+ function readLoop(array) {
53
+ return `{ ${array}=(); while IFS= read -r || [ -n "$REPLY" ]; do ${array}+=("$REPLY"); done; }`;
54
+ }
55
+ /**
56
+ * The array a directly written `mapfile -t [NAME]` fills, or `undefined` when
57
+ * `node` has any other shape (see the module comment), so no suggestion is
58
+ * offered.
59
+ */
60
+ function rewritableArray(sourceCode, node) {
61
+ const command = sourceCode.getCommandName(node);
62
+ if (command === null || !MAPFILE_COMMANDS.has(command))
63
+ return undefined;
64
+ if (node.assigns.length > 0)
65
+ return undefined;
66
+ const words = node.args.slice(1).map((word) => sourceCode.resolveWordText(word));
67
+ if (words.length === 0 || words.length > 2 || words[0] !== '-t')
68
+ return undefined;
69
+ const name = words.length === 2 ? words[1] : DEFAULT_ARRAY;
70
+ return name !== null && IDENTIFIER.test(name) ? name : undefined;
71
+ }
72
+ /**
73
+ * The `sh/compat/no-mapfile` rule module.
74
+ *
75
+ * @internal
76
+ */
77
+ export const noMapfile = createCompatRule({
78
+ name: 'compat/no-mapfile',
79
+ feature: 'mapfile',
80
+ meta: {
81
+ type: 'problem',
82
+ hasSuggestions: true,
83
+ docs: {
84
+ description: 'Disallow `mapfile` and `readarray` when the target shell lacks them',
85
+ },
86
+ messages: {
87
+ mapfile: '`{{command}}` needs {{required}}, but this file targets {{target}}. Read the lines with a `while IFS= read -r` loop instead.',
88
+ useReadLoop: 'Replace with a `while IFS= read -r` loop that appends each line to `{{array}}` (the loop also sets `REPLY` to the empty string).',
89
+ },
90
+ },
91
+ create(context, gate) {
92
+ const { sourceCode } = context;
93
+ const arraysAvailable = sourceCode.dialect !== 'posix';
94
+ return {
95
+ CallExpr(node) {
96
+ const invoked = sourceCode.resolveInvokedCommand(node, {
97
+ transparentWrappers: TRANSPARENT_WRAPPERS,
98
+ });
99
+ if (invoked?.effective.static !== true)
100
+ return;
101
+ const command = invoked.effective.text;
102
+ if (!MAPFILE_COMMANDS.has(command))
103
+ return;
104
+ const array = arraysAvailable ? rewritableArray(sourceCode, node) : undefined;
105
+ context.report({
106
+ node,
107
+ messageId: 'mapfile',
108
+ data: { ...gate, command },
109
+ suggest: array === undefined
110
+ ? []
111
+ : [
112
+ {
113
+ messageId: 'useReadLoop',
114
+ data: { array },
115
+ fix: (fixer) => fixer.replaceText(node, readLoop(array)),
116
+ },
117
+ ],
118
+ });
119
+ },
120
+ };
121
+ },
122
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Message ids this rule reports with.
3
+ *
4
+ * @internal
5
+ */
6
+ export type NoNamerefMessageId = 'nameref' | 'namerefPosix';
7
+ /**
8
+ * The `sh/compat/no-nameref` rule module.
9
+ *
10
+ * @internal
11
+ */
12
+ export declare const noNameref: import("../define-shell-rule.js").ShellRule<[], NoNamerefMessageId>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `sh/compat/no-nameref`: reports namerefs (`declare -n`, `typeset -n`,
3
+ * `local -n`) in files whose target shell lacks them, that is, Bash older
4
+ * than 4.3 (macOS's stock `/bin/bash` is 3.2) and POSIX sh.
5
+ *
6
+ * Bash 4.2 and older reject `-n` as an invalid option, so the reference is
7
+ * never made and every assignment through it lands in a variable of its own
8
+ * name instead. The message suggests the portable alternative for the
9
+ * target: in Bash, assign by name with `printf -v "$name" '%s' "$value"`
10
+ * and read by name with `${!name}`, both available in Bash 3.2; in POSIX
11
+ * sh, which has neither, use `eval`.
12
+ *
13
+ * What is read: `declare`/`typeset`/`local` option clusters containing `n`
14
+ * (`-n`, `-rn`), found as `declarationFlagVisitor` defines. The report is on
15
+ * the option word.
16
+ */
17
+ import { createCompatRule } from './create-compat-rule.js';
18
+ import { DECLARATION_AND_LOCAL_COMMANDS, declarationFlagVisitor } from './declaration-options.js';
19
+ /**
20
+ * The `sh/compat/no-nameref` rule module.
21
+ *
22
+ * @internal
23
+ */
24
+ export const noNameref = createCompatRule({
25
+ name: 'compat/no-nameref',
26
+ feature: 'nameref',
27
+ meta: {
28
+ type: 'problem',
29
+ docs: {
30
+ description: 'Disallow namerefs (`declare -n`, `local -n`) when the target shell lacks them',
31
+ },
32
+ messages: {
33
+ nameref: '`{{command}} -n` needs {{required}}, but this file targets {{target}}. Pass the variable name instead: assign through it with `printf -v "$name" \'%s\' "$value"` and read it with `${!name}`.',
34
+ namerefPosix: '`{{command}} -n` needs {{required}}, but this file targets {{target}}. Pass the variable name instead and use `eval`: assign with `eval "$name=\\$value"` and read with `eval "value=\\$$name"`, after checking that the name is a valid identifier.',
35
+ },
36
+ },
37
+ create(context, gate) {
38
+ const { sourceCode } = context;
39
+ const messageId = sourceCode.dialect === 'posix' ? 'namerefPosix' : 'nameref';
40
+ return declarationFlagVisitor(sourceCode, 'n', (node, command) => {
41
+ context.report({ node, messageId, data: { ...gate, command } });
42
+ }, DECLARATION_AND_LOCAL_COMMANDS);
43
+ },
44
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `negativeIndex` for the report and
3
+ * `useArrayLength` for its suggestion.
4
+ *
5
+ * @internal
6
+ */
7
+ export type NoNegativeArrayIndexMessageId = 'negativeIndex' | 'useArrayLength';
8
+ /**
9
+ * The `sh/compat/no-negative-array-index` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const noNegativeArrayIndex: import("../define-shell-rule.js").ShellRule<[], NoNegativeArrayIndexMessageId>;
@@ -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>;