@eslint-sh/eslint-plugin 0.6.0 → 0.8.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 (147) hide show
  1. package/README.md +19 -8
  2. package/dist/command-harvest/builtins/bash.d.ts +1 -1
  3. package/dist/command-harvest/builtins/bash.js +1 -1
  4. package/dist/command-harvest/builtins/bats.d.ts +2 -0
  5. package/dist/command-harvest/builtins/bats.js +3 -0
  6. package/dist/command-harvest/wrapper-table.d.ts +3 -19
  7. package/dist/command-harvest/wrapper-table.js +8 -38
  8. package/dist/configs/bash-3-2.d.ts +7 -6
  9. package/dist/configs/bash-3-2.js +5 -3
  10. package/dist/configs/posix-portable.d.ts +1 -1
  11. package/dist/configs/posix-portable.js +1 -0
  12. package/dist/declaration-conventions/read-declared-commands.d.ts +2 -2
  13. package/dist/declaration-conventions/read-declared-commands.js +2 -2
  14. package/dist/eslint-plugin-syntax.d.ts +4 -0
  15. package/dist/eslint-plugin.d.ts +86 -40
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.js +4 -2
  18. package/dist/language/shell-language-options.d.ts +18 -15
  19. package/dist/language/shell-language-options.js +33 -26
  20. package/dist/language/shell-language.d.ts +10 -9
  21. package/dist/language/shell-language.js +18 -35
  22. package/dist/language/shell-source-code.d.ts +17 -9
  23. package/dist/language/shell-source-code.js +53 -12
  24. package/dist/rules/bash-variable-semantics.d.ts +22 -0
  25. package/dist/rules/bash-variable-semantics.js +41 -0
  26. package/dist/rules/compat/create-compat-rule.js +1 -1
  27. package/dist/rules/compat/declaration-options.d.ts +2 -2
  28. package/dist/rules/compat/declaration-options.js +5 -2
  29. package/dist/rules/compat/no-append-all-redirect.js +1 -1
  30. package/dist/rules/compat/no-associative-arrays.js +2 -2
  31. package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
  32. package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
  33. package/dist/rules/compat/no-case-attributes.js +3 -3
  34. package/dist/rules/compat/no-case-fallthrough.js +2 -2
  35. package/dist/rules/compat/no-case-modification.js +2 -2
  36. package/dist/rules/compat/no-coproc.js +1 -1
  37. package/dist/rules/compat/no-declare-g.js +3 -3
  38. package/dist/rules/compat/no-double-bracket.js +2 -2
  39. package/dist/rules/compat/no-epoch-variables.js +2 -2
  40. package/dist/rules/compat/no-fd-variable.js +1 -1
  41. package/dist/rules/compat/no-globstar.js +1 -1
  42. package/dist/rules/compat/no-lastpipe-semantics.js +2 -2
  43. package/dist/rules/compat/no-let.js +2 -1
  44. package/dist/rules/compat/no-mapfile.js +4 -2
  45. package/dist/rules/compat/no-nameref.js +3 -3
  46. package/dist/rules/compat/no-negative-array-index.js +39 -9
  47. package/dist/rules/compat/no-nounset-empty-array.js +15 -4
  48. package/dist/rules/compat/no-parameter-transformation.js +2 -2
  49. package/dist/rules/compat/no-pipe-all.js +1 -1
  50. package/dist/rules/compat/no-printf-time-format.js +4 -2
  51. package/dist/rules/compat/no-source.js +2 -1
  52. package/dist/rules/compat/no-test-v.js +4 -2
  53. package/dist/rules/compat/no-unicode-escape.js +2 -2
  54. package/dist/rules/compat/no-wait-n.js +4 -2
  55. package/dist/rules/compat/nounset-array-proof.d.ts +8 -0
  56. package/dist/rules/compat/nounset-array-proof.js +563 -0
  57. package/dist/rules/compat/posix-absent-command.d.ts +7 -8
  58. package/dist/rules/compat/posix-absent-command.js +17 -15
  59. package/dist/rules/compat/shopt-options.js +3 -1
  60. package/dist/rules/compat/version-gates.d.ts +10 -10
  61. package/dist/rules/compat/version-gates.js +21 -13
  62. package/dist/rules/create-wrapper-rule.js +12 -7
  63. package/dist/rules/index.js +2 -0
  64. package/dist/rules/no-dynamic-invocation.d.ts +0 -8
  65. package/dist/rules/no-dynamic-invocation.js +7 -15
  66. package/dist/rules/no-eval.js +3 -1
  67. package/dist/rules/no-masked-exit-status.js +15 -8
  68. package/dist/rules/no-secret-env-reads.d.ts +4 -3
  69. package/dist/rules/no-secret-env-reads.js +37 -14
  70. package/dist/rules/no-secret-flags.d.ts +2 -1
  71. package/dist/rules/no-secret-flags.js +7 -8
  72. package/dist/rules/no-unchecked-cd.js +3 -1
  73. package/dist/rules/no-undeclared-commands.d.ts +1 -15
  74. package/dist/rules/no-undeclared-commands.js +3 -15
  75. package/dist/rules/no-unused-declarations.d.ts +1 -18
  76. package/dist/rules/no-unused-declarations.js +9 -34
  77. package/dist/rules/prefer-local.js +4 -3
  78. package/dist/rules/quote-expansions.js +3 -28
  79. package/dist/rules/require-network-wrapper.d.ts +2 -17
  80. package/dist/rules/require-network-wrapper.js +33 -4
  81. package/dist/rules/require-tty-guard.js +30 -1
  82. package/dist/rules/resolve-declaration-convention.d.ts +5 -6
  83. package/dist/rules/resolve-declaration-convention.js +5 -6
  84. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  85. package/dist/rules/resolve-network-wrapper.js +57 -26
  86. package/dist/rules/resolve-transparent-wrappers.d.ts +43 -0
  87. package/dist/rules/resolve-transparent-wrappers.js +98 -0
  88. package/dist/rules/set-options.d.ts +2 -1
  89. package/dist/rules/set-options.js +17 -5
  90. package/dist/rules/shebang-interpreter.js +4 -2
  91. package/dist/rules/transparent-wrappers.d.ts +4 -3
  92. package/dist/rules/transparent-wrappers.js +5 -4
  93. package/dist/rules/user-pattern.d.ts +5 -3
  94. package/dist/rules/user-pattern.js +6 -4
  95. package/dist/syntax/index.d.ts +4 -0
  96. package/dist/syntax/index.js +4 -0
  97. package/docs/compat.md +90 -34
  98. package/docs/custom-rule-tutorial.md +9 -3
  99. package/docs/examples/no-listed-commands-rule.ts +2 -2
  100. package/docs/rules/README.md +38 -5
  101. package/docs/rules/_template.md +2 -2
  102. package/docs/rules/compat/no-append-all-redirect.md +12 -12
  103. package/docs/rules/compat/no-associative-arrays.md +14 -14
  104. package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
  105. package/docs/rules/compat/no-case-attributes.md +13 -13
  106. package/docs/rules/compat/no-case-fallthrough.md +12 -12
  107. package/docs/rules/compat/no-case-modification.md +11 -11
  108. package/docs/rules/compat/no-coproc.md +13 -13
  109. package/docs/rules/compat/no-declare-g.md +13 -13
  110. package/docs/rules/compat/no-double-bracket.md +2 -2
  111. package/docs/rules/compat/no-epoch-variables.md +13 -13
  112. package/docs/rules/compat/no-fd-variable.md +12 -12
  113. package/docs/rules/compat/no-globstar.md +13 -13
  114. package/docs/rules/compat/no-lastpipe-semantics.md +13 -13
  115. package/docs/rules/compat/no-let.md +2 -2
  116. package/docs/rules/compat/no-mapfile.md +16 -16
  117. package/docs/rules/compat/no-nameref.md +13 -13
  118. package/docs/rules/compat/no-negative-array-index.md +14 -14
  119. package/docs/rules/compat/no-nounset-empty-array.md +72 -17
  120. package/docs/rules/compat/no-parameter-transformation.md +19 -19
  121. package/docs/rules/compat/no-pipe-all.md +13 -13
  122. package/docs/rules/compat/no-printf-time-format.md +13 -13
  123. package/docs/rules/compat/no-source.md +2 -2
  124. package/docs/rules/compat/no-test-v.md +12 -12
  125. package/docs/rules/compat/no-unicode-escape.md +13 -13
  126. package/docs/rules/compat/no-wait-n.md +13 -13
  127. package/docs/rules/no-dynamic-invocation.md +17 -12
  128. package/docs/rules/no-eval.md +6 -1
  129. package/docs/rules/no-masked-exit-status.md +4 -0
  130. package/docs/rules/no-secret-env-reads.md +34 -3
  131. package/docs/rules/no-secret-flags.md +19 -2
  132. package/docs/rules/no-unchecked-cd.md +9 -2
  133. package/docs/rules/no-undeclared-commands.md +16 -15
  134. package/docs/rules/no-unused-declarations.md +17 -20
  135. package/docs/rules/prefer-local.md +20 -2
  136. package/docs/rules/quote-expansions.md +8 -5
  137. package/docs/rules/require-network-wrapper.md +22 -17
  138. package/docs/rules/require-shebang.md +10 -4
  139. package/docs/rules/require-strict-mode.md +6 -0
  140. package/docs/rules/require-tty-guard.md +4 -1
  141. package/docs/shellcheck.md +10 -5
  142. package/docs/transparent-wrappers.md +97 -0
  143. package/docs/untrusted-code.md +440 -0
  144. package/docs/wrapper-rules.md +9 -0
  145. package/package.json +2 -2
  146. package/dist/language/end-of-input-marker.d.ts +0 -29
  147. package/dist/language/end-of-input-marker.js +0 -88
@@ -14,6 +14,7 @@
14
14
  * {@link DECLARATION_AND_LOCAL_COMMANDS}.
15
15
  */
16
16
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
17
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
17
18
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
18
19
  /** The declaration commands that take option clusters. */
19
20
  const DECLARATION_COMMANDS = new Set(['declare', 'typeset']);
@@ -78,7 +79,9 @@ function declClauseArgs(node) {
78
79
  *
79
80
  * @internal
80
81
  */
81
- export function declarationFlagVisitor(sourceCode, flag, report, commands = DECLARATION_COMMANDS) {
82
+ export function declarationFlagVisitor(context, flag, report, commands = DECLARATION_COMMANDS) {
83
+ const { sourceCode } = context;
84
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
82
85
  const flagPattern = new RegExp(`^-[a-zA-Z]*${flag}[a-zA-Z]*$`, 'u');
83
86
  return {
84
87
  DeclClause(node) {
@@ -91,7 +94,7 @@ export function declarationFlagVisitor(sourceCode, flag, report, commands = DECL
91
94
  },
92
95
  CallExpr(node) {
93
96
  const invoked = sourceCode.resolveInvokedCommand(node, {
94
- transparentWrappers: TRANSPARENT_WRAPPERS,
97
+ transparentWrappers,
95
98
  });
96
99
  if (invoked?.effective.static !== true)
97
100
  return;
@@ -37,7 +37,7 @@ export const noAppendAllRedirect = createCompatRule({
37
37
  description: 'Disallow the `&>>` redirection when the target shell lacks it',
38
38
  },
39
39
  messages: {
40
- appendAll: '`&>>` needs {{required}}, but this file targets {{target}}. Append both streams with `>> file 2>&1` instead.',
40
+ appendAll: '`&>>` needs {{required}}, but this file must support {{target}}. Append both streams with `>> file 2>&1` instead.',
41
41
  useAppendRedirects: 'Replace `&>> {{file}}` with `>> {{file}} 2>&1`.',
42
42
  },
43
43
  },
@@ -28,11 +28,11 @@ export const noAssociativeArrays = createCompatRule({
28
28
  description: 'Disallow associative arrays (`declare -A`, `typeset -A`) when the target shell lacks them',
29
29
  },
30
30
  messages: {
31
- associativeArray: 'Associative arrays (`{{command}} -A`) need {{required}}, but this file targets {{target}}. Consider moving the keyed lookup into `jq`, for example `jq -r --arg k "$key" \'.[$k]\' map.json`.',
31
+ associativeArray: 'Associative arrays (`{{command}} -A`) need {{required}}, but this file must support {{target}}. Consider moving the keyed lookup into `jq`, for example `jq -r --arg k "$key" \'.[$k]\' map.json`.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
35
- return declarationFlagVisitor(context.sourceCode, 'A', (node, command) => {
35
+ return declarationFlagVisitor(context, 'A', (node, command) => {
36
36
  context.report({ node, messageId: 'associativeArray', data: { ...gate, command } });
37
37
  });
38
38
  },
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Message ids this rule reports with: `padded` for a zero-padded sequence on
3
+ * a Bash that expands it without the padding, `stepped` for a stepped one on
4
+ * a Bash that leaves it literal, and `posixPadded` / `posixStepped` for the
5
+ * same two under a POSIX target, where the result depends on the shell.
6
+ *
7
+ * @internal
8
+ */
9
+ export type NoBraceSequencePaddingStepMessageId = 'padded' | 'stepped' | 'posixPadded' | 'posixStepped';
10
+ /**
11
+ * The `sh/compat/no-brace-sequence-padding-step` rule module.
12
+ *
13
+ * @internal
14
+ */
15
+ export declare const noBraceSequencePaddingStep: import("../define-shell-rule.js").ShellRule<[], NoBraceSequencePaddingStepMessageId>;
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `sh/compat/no-brace-sequence-padding-step`: reports a brace sequence that
3
+ * is zero-padded (`{01..10}`) or stepped (`{1..10..2}`, `{a..e..2}`) in
4
+ * files whose target shell lacks those forms, that is, Bash older than 4.0
5
+ * (macOS's stock `/bin/bash` is 3.2) and POSIX sh.
6
+ *
7
+ * Neither form is an error on Bash 3.2, which is what makes them easy to
8
+ * miss. Bash 3.2 expands `{01..03}` to `1 2 3`, dropping the padding, and
9
+ * leaves `{1..9..4}` as that literal word, so the script carries on with
10
+ * different words. POSIX does not specify brace expansion, so under a POSIX
11
+ * target what a shell does with the word depends on the shell (observed: dash
12
+ * leaves it literal, macOS's `/bin/sh` drops the padding as Bash 3.2 does,
13
+ * and `bash --posix` 5.3 pads); a plain `{1..3}` is as shell-dependent there,
14
+ * and this rule does not report it: only the Bash 4.0 additions are in its
15
+ * scope. The
16
+ * message suggests the portable alternative: count in a `while` loop and
17
+ * format with `printf`.
18
+ *
19
+ * What is read: the parser keeps a brace sequence as literal text inside a
20
+ * word, so the rule scans the literal parts of words that the shell
21
+ * brace-expands. A sequence is a `{` and `}` with nothing but the sequence
22
+ * between them (so `{01..03,x}`, a comma list, is not one), where:
23
+ *
24
+ * - the endpoints are both integers (an optional sign and digits) or both
25
+ * single ASCII letters, with an optional third `..` field that is an
26
+ * integer step: that makes it stepped, for either kind of endpoint;
27
+ * - integer endpoints are padded when either has more than one digit and
28
+ * starts with `0` after an optional `-` (`01`, `00`, `-01`, `1..03`; `0`,
29
+ * `-0` and `+01` are not).
30
+ *
31
+ * Anything that is not a sequence in Bash 4.0 and newer is left alone:
32
+ * `{a..c}` and `{1..3}` (no padding, no step, the same on every Bash),
33
+ * mixed endpoints (`{a..5..2}`), a variable endpoint (`{01..$n}`, which the
34
+ * shell never expands), an unterminated or malformed one (`{01..}`), and a
35
+ * `{` or `}` that is quoted or backslash-escaped, since quoted text is a
36
+ * separate part of the word and an escape is skipped when scanning.
37
+ *
38
+ * Numbers are bounded as Bash 5.3 bounds them, because past the bounds the
39
+ * word stays literal on every Bash and nothing differs: endpoints must fit a
40
+ * signed 64-bit integer, so must the distance between them, and so must the
41
+ * step's magnitude (a step of -9223372036854775808 or a value past 64 bits
42
+ * leaves the word literal;
43
+ * leading zeros do not count towards the value, so `{1..00000000000000000003}`
44
+ * is a sequence). Bash 3.2 reads the same words as literal or wraps them
45
+ * (its integers are narrower), and the older 4.x releases' bound was not
46
+ * measured, so a sequence inside the 64-bit range is reported and one outside
47
+ * it is not.
48
+ *
49
+ * Only words that are brace-expanded are read: command words and arguments,
50
+ * `for`/`select` lists, the elements of an array literal without an index,
51
+ * and the operands of `declare`, `local`, `export` and `readonly`. Not read,
52
+ * because the shell does no brace expansion there: a plain assignment
53
+ * (`a={01..03}`), a `case` subject or pattern, the operands of `[[ ]]`,
54
+ * here-strings and here-documents, and parameter-expansion words.
55
+ *
56
+ * A descriptor duplication target is not reported where it behaves the same
57
+ * on every Bash; see `isSameOnEveryBash`.
58
+ *
59
+ * Text inside a quoted string that the shell later runs as code (`eval
60
+ * '...'`, `trap '...'`) is not seen: the parser holds it as a string.
61
+ *
62
+ * A redirection target (`: > out{01..01}`) is read only when the sequence
63
+ * can be shown to yield exactly one word, since several words make it an
64
+ * "ambiguous redirect" error on Bash 4 and newer (on 3.2 too for a padded
65
+ * one), and the script is then wrong on every Bash that has the feature,
66
+ * not a portability gap. The word must hold that one sequence and no other
67
+ * brace group, whose product with it could not be counted here. A single-word
68
+ * result differs by target: `out{01..01}` names `out1` on Bash 3.2 and
69
+ * `out01` on 5.3, and `out{1..3..5}` names a file called `out{1..3..5}` on
70
+ * 3.2 and `out1` on 5.3. A sequence is always static text here (a variable
71
+ * endpoint is no sequence), so its length is always known. The report is on
72
+ * the whole word, once per word.
73
+ */
74
+ import { createCompatRule } from './create-compat-rule.js';
75
+ import { DashHdoc, DplIn, DplOut, Hdoc, WordHdoc } from 'sh-ast/analyze';
76
+ /** The sequence between braces: two endpoints and an optional step. */
77
+ const SEQUENCE = /^([+-]?\d+|[A-Za-z])\.\.([+-]?\d+|[A-Za-z])(?:\.\.([+-]?\d+))?$/u;
78
+ /**
79
+ * An integer endpoint with leading zeros, which Bash 4.0 pads to. A `+` sign
80
+ * does not count: Bash 5.3 expands `{+01..3}` as `1 2 3`, as 3.2 does.
81
+ */
82
+ const ZERO_PADDED = /^-?0\d+$/u;
83
+ /** An integer endpoint. */
84
+ const INTEGER = /^[+-]?\d+$/u;
85
+ /** The signed 64-bit range Bash 5.3 accepts for an endpoint. */
86
+ const INT64_MIN = -(2n ** 63n);
87
+ const INT64_MAX = 2n ** 63n - 1n;
88
+ /**
89
+ * `text` with every backslash escape (the backslash and the character it
90
+ * quotes) replaced by `\0`, which no sequence contains, so an escaped brace
91
+ * cannot open or close a sequence and an escape inside one disqualifies it.
92
+ */
93
+ function maskEscapes(text) {
94
+ let masked = '';
95
+ for (let index = 0; index < text.length; index++) {
96
+ if (text[index] === '\\') {
97
+ masked += '\0\0';
98
+ index++;
99
+ }
100
+ else {
101
+ masked += text[index];
102
+ }
103
+ }
104
+ return masked;
105
+ }
106
+ /** The value of an endpoint: an integer's number, or a letter's code point. */
107
+ function endpointValue(endpoint) {
108
+ return INTEGER.test(endpoint) ? BigInt(endpoint) : BigInt(endpoint.charCodeAt(0));
109
+ }
110
+ /**
111
+ * The number of words a valid sequence yields. A step of zero counts as 1,
112
+ * and a step's sign is ignored: the direction follows the endpoints.
113
+ */
114
+ function resultCount(start, end, step) {
115
+ const size = step === undefined || step === 0n ? 1n : step < 0n ? -step : step;
116
+ return span(start, end) / size + 1n;
117
+ }
118
+ /** The distance between two endpoints. */
119
+ function span(start, end) {
120
+ return end > start ? end - start : start - end;
121
+ }
122
+ /**
123
+ * The first brace sequence in the literal text `text` that needs Bash 4.0
124
+ * and that Bash 5.3 expands, or `undefined`. Innermost brace pairs are the
125
+ * only candidates, which is where Bash finds a sequence, including one
126
+ * nested in a comma list (`{x,{01..03}}`).
127
+ */
128
+ function findSequence(text) {
129
+ const masked = maskEscapes(text);
130
+ for (const pair of masked.matchAll(/\{([^{}]*)\}/gu)) {
131
+ const match = SEQUENCE.exec(pair[1]);
132
+ if (match === null)
133
+ continue;
134
+ const [, start, end] = match;
135
+ const integers = INTEGER.test(start) && INTEGER.test(end);
136
+ const letters = !INTEGER.test(start) && !INTEGER.test(end);
137
+ if (!integers && !letters)
138
+ continue;
139
+ // A third `..` field is the step (SEQUENCE has already checked its form).
140
+ const stepText = match[0].split('..').length === 3 ? match[0].split('..')[2] : undefined;
141
+ const step = stepText === undefined ? undefined : BigInt(stepText);
142
+ const first = endpointValue(start);
143
+ const last = endpointValue(end);
144
+ const inRange = first >= INT64_MIN &&
145
+ first <= INT64_MAX &&
146
+ last >= INT64_MIN &&
147
+ last <= INT64_MAX &&
148
+ (step === undefined || (step > INT64_MIN && step <= INT64_MAX));
149
+ // The distance between the endpoints must fit too:
150
+ // `{-1..9223372036854775807..9223372036854775807}` stays literal on Bash
151
+ // 5.3 although every number in it is in range.
152
+ if (!inRange || span(first, last) > INT64_MAX)
153
+ continue;
154
+ const results = resultCount(first, last, step);
155
+ const written = text.slice(pair.index, pair.index + pair[0].length);
156
+ if (step !== undefined)
157
+ return { text: written, kind: 'stepped', results, first };
158
+ if (integers && (ZERO_PADDED.test(start) || ZERO_PADDED.test(end))) {
159
+ return { text: written, kind: 'padded', results, first };
160
+ }
161
+ }
162
+ return undefined;
163
+ }
164
+ /** How many unescaped `{` the literal parts of `word` hold. */
165
+ function braceGroupCount(word) {
166
+ let count = 0;
167
+ for (const part of word.parts) {
168
+ if (part.type === 'Lit')
169
+ count += maskEscapes(part.value).split('{').length - 1;
170
+ }
171
+ return count;
172
+ }
173
+ /** Redirection operators whose word the shell does not brace-expand. */
174
+ const NO_BRACE_REDIRECTS = new Set([Hdoc, DashHdoc, WordHdoc]);
175
+ /** The `<&` and `>&` operators, whose lone-number word names a descriptor. */
176
+ const DESCRIPTOR_DUPLICATES = new Set([DplIn, DplOut]);
177
+ /**
178
+ * Whether a one-word sequence that is the whole target of `<&` or `>&`
179
+ * behaves the same on Bash 3.2 and newer (checked on 3.2.57 and 5.3.9):
180
+ *
181
+ * - a descriptor position (`<&`, or `>&` with a descriptor before it, as in
182
+ * `2>&`) with a negative number is an "ambiguous redirect" error on both,
183
+ * whatever the form, so there is nothing to port;
184
+ * - a padded non-negative number names the same descriptor on both (`>&{01..01}`
185
+ * is descriptor 1), but a stepped one differs (`>&{1..3..5}` is literal on
186
+ * 3.2 and descriptor 1 on 5.3);
187
+ * - a bare `>&` with a word that is not a descriptor writes a file, so a
188
+ * negative number is a file name that differs (`-1` on 3.2, `-01` on 5.3).
189
+ */
190
+ function isSameOnEveryBash(redirect, sequence) {
191
+ if (sequence.results !== 1n)
192
+ return false;
193
+ const descriptorPosition = redirect.op === DplIn || redirect.n !== undefined;
194
+ if (sequence.first < 0n)
195
+ return descriptorPosition;
196
+ return sequence.kind === 'padded';
197
+ }
198
+ /**
199
+ * Whether the shell brace-expands `word`, a `Word` node whose parent is
200
+ * `parent`. This is the complete list of such positions; anything not named
201
+ * here is exempt.
202
+ */
203
+ function isBraceExpanded(word, parent, grandparent) {
204
+ switch (parent?.type) {
205
+ case 'CallExpr':
206
+ case 'WordIter':
207
+ return true;
208
+ // An array literal element; a keyed element (`[k]=v`) is assigned like a
209
+ // variable and is not expanded.
210
+ case 'ArrayElem':
211
+ return parent.index === undefined && parent.value === word;
212
+ // A declaration's operands are arguments to the builtin, so both a bare
213
+ // one and the value of `NAME=value` are expanded; a plain assignment is
214
+ // not.
215
+ case 'Assign':
216
+ return grandparent?.type === 'DeclClause';
217
+ // The target of an ordinary redirection; a here-document's delimiter and
218
+ // a here-string's word are not brace-expanded.
219
+ case 'Redirect':
220
+ return parent.word === word && !NO_BRACE_REDIRECTS.has(parent.op);
221
+ default:
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * The `sh/compat/no-brace-sequence-padding-step` rule module.
227
+ *
228
+ * @internal
229
+ */
230
+ export const noBraceSequencePaddingStep = createCompatRule({
231
+ name: 'compat/no-brace-sequence-padding-step',
232
+ feature: 'braceSequencePaddingStep',
233
+ meta: {
234
+ type: 'problem',
235
+ docs: {
236
+ description: 'Disallow zero-padded (`{01..10}`) and stepped (`{1..10..2}`) brace sequences when the target shell lacks them',
237
+ },
238
+ messages: {
239
+ padded: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which expands it without the zero padding. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
240
+ stepped: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which leaves it as that literal word. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
241
+ posixPadded: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
242
+ posixStepped: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
243
+ },
244
+ },
245
+ create(context, gate) {
246
+ const { sourceCode } = context;
247
+ const posix = sourceCode.dialect === 'posix';
248
+ return {
249
+ Word(node) {
250
+ const parent = sourceCode.getParent(node);
251
+ const grandparent = parent === undefined ? undefined : sourceCode.getParent(parent);
252
+ if (!isBraceExpanded(node, parent, grandparent))
253
+ return;
254
+ const redirectTarget = parent?.type === 'Redirect';
255
+ for (const part of node.parts) {
256
+ if (part.type !== 'Lit')
257
+ continue;
258
+ const sequence = findSequence(part.value);
259
+ if (sequence === undefined)
260
+ continue;
261
+ // A redirection target is an ambiguous-redirect error unless it is
262
+ // exactly one word, which is only certain for a lone sequence.
263
+ if (redirectTarget && (sequence.results !== 1n || braceGroupCount(node) !== 1))
264
+ return;
265
+ if (!posix &&
266
+ parent?.type === 'Redirect' &&
267
+ DESCRIPTOR_DUPLICATES.has(parent.op) &&
268
+ node.parts.length === 1 &&
269
+ part.value === sequence.text &&
270
+ isSameOnEveryBash(parent, sequence)) {
271
+ return;
272
+ }
273
+ context.report({
274
+ node,
275
+ messageId: posix
276
+ ? sequence.kind === 'padded'
277
+ ? 'posixPadded'
278
+ : 'posixStepped'
279
+ : sequence.kind,
280
+ data: { ...gate, sequence: sequence.text },
281
+ });
282
+ return;
283
+ }
284
+ },
285
+ };
286
+ },
287
+ });
@@ -36,13 +36,13 @@ export const noCaseAttributes = createCompatRule({
36
36
  description: 'Disallow the case attributes `declare -l` and `declare -u` when the target shell lacks them',
37
37
  },
38
38
  messages: {
39
- caseAttribute: '`{{command}} -{{flag}}` needs {{required}}, but this file targets {{target}}. Convert the value when you assign it instead, for example `v=$(printf \'%s\' "$v" | {{tr}})`.',
40
- caseAttributesBoth: '`{{command}} {{option}}` needs {{required}}, but this file targets {{target}}. Where it is supported, `-l` and `-u` together cancel out and leave the value as written, so drop both letters.',
39
+ caseAttribute: '`{{command}} -{{flag}}` needs {{required}}, but this file must support {{target}}. Convert the value when you assign it instead, for example `v=$(printf \'%s\' "$v" | {{tr}})`.',
40
+ caseAttributesBoth: '`{{command}} {{option}}` needs {{required}}, but this file must support {{target}}. Where it is supported, `-l` and `-u` together cancel out and leave the value as written, so drop both letters.',
41
41
  },
42
42
  },
43
43
  create(context, gate) {
44
44
  const { sourceCode } = context;
45
- return declarationFlagVisitor(sourceCode, '[lu]', (node, command) => {
45
+ return declarationFlagVisitor(context, '[lu]', (node, command) => {
46
46
  const option = (node.type === 'Word' ? sourceCode.resolveWordText(node) : null) ?? '';
47
47
  if (option.includes('l') && option.includes('u')) {
48
48
  context.report({
@@ -37,8 +37,8 @@ export const noCaseFallthrough = createCompatRule({
37
37
  description: 'Disallow the `case` terminators `;&` and `;;&` when the target shell lacks them',
38
38
  },
39
39
  messages: {
40
- fallthrough: "`;&` needs {{required}}, but this file targets {{target}}. Repeat the next branch's commands at the end of this branch and end it with `;;` instead.",
41
- resume: '`;;&` needs {{required}}, but this file targets {{target}}. End this branch with `;;` and test the patterns that follow it in a second `case` statement instead.',
40
+ fallthrough: "`;&` needs {{required}}, but this file must support {{target}}. Repeat the next branch's commands at the end of this branch and end it with `;;` instead.",
41
+ resume: '`;;&` needs {{required}}, but this file must support {{target}}. End this branch with `;;` and test the patterns that follow it in a second `case` statement instead.',
42
42
  },
43
43
  },
44
44
  create(context, gate) {
@@ -55,8 +55,8 @@ export const noCaseModification = createCompatRule({
55
55
  description: 'Disallow case-modifying expansions (`${v^^}`, `${v,,}` and variants) when the target shell lacks them',
56
56
  },
57
57
  messages: {
58
- caseModification: '`${…{{operator}}}` needs {{required}}, but this file targets {{target}}. Convert the case with `tr` instead, for example {{example}}.',
59
- caseModificationPattern: '`${…{{operator}}pattern}` needs {{required}}, but this file targets {{target}}. It changes only the characters the pattern matches, so a plain case-converting `tr` is not equivalent; spell out the matching characters instead (for example `tr a A`).',
58
+ caseModification: '`${…{{operator}}}` needs {{required}}, but this file must support {{target}}. Convert the case with `tr` instead, for example {{example}}.',
59
+ caseModificationPattern: '`${…{{operator}}pattern}` needs {{required}}, but this file must support {{target}}. It changes only the characters the pattern matches, so a plain case-converting `tr` is not equivalent; spell out the matching characters instead (for example `tr a A`).',
60
60
  },
61
61
  },
62
62
  create(context, gate) {
@@ -28,7 +28,7 @@ export const noCoproc = createCompatRule({
28
28
  description: 'Disallow the `coproc` reserved word when the target shell lacks it',
29
29
  },
30
30
  messages: {
31
- coproc: '`coproc` needs {{required}}, but this file targets {{target}}. Use a named pipe instead: create it with `mkfifo`, start the coprocess in the background reading or writing the pipe, and open the pipe from the script.',
31
+ coproc: '`coproc` needs {{required}}, but this file must support {{target}}. Use a named pipe instead: create it with `mkfifo`, start the coprocess in the background reading or writing the pipe, and open the pipe from the script.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -39,12 +39,12 @@ export const noDeclareG = createCompatRule({
39
39
  description: 'Disallow `declare -g` and `typeset -g` when the target shell lacks the option',
40
40
  },
41
41
  messages: {
42
- declareGlobal: '`{{command}} -g` needs {{required}}, but this file targets {{target}}. Assign the global with a plain `name=value` (a variable not declared `local` is global), or `export name=value` to also export it.',
43
- declareGlobalWithAttributes: '`{{command}} {{flags}}` needs {{required}}, but this file targets {{target}}. A plain `name=value` cannot express its other options, so dropping `g` is not enough: declare the variable outside any function, or raise the target.',
42
+ declareGlobal: '`{{command}} -g` needs {{required}}, but this file must support {{target}}. Assign the global with a plain `name=value` (a variable not declared `local` is global), or `export name=value` to also export it.',
43
+ declareGlobalWithAttributes: '`{{command}} {{flags}}` needs {{required}}, but this file must support {{target}}. A plain `name=value` cannot express its other options, so dropping `g` is not enough: declare the variable outside any function, or raise the minimum Bash version.',
44
44
  },
45
45
  },
46
46
  create(context, gate) {
47
- return declarationFlagVisitor(context.sourceCode, 'g', (node, command, options) => {
47
+ return declarationFlagVisitor(context, 'g', (node, command, options) => {
48
48
  if (!isPlainAssignmentEquivalent(options)) {
49
49
  context.report({
50
50
  node,
@@ -39,10 +39,10 @@ export const noDoubleBracket = createCompatRule({
39
39
  },
40
40
  },
41
41
  create(context, gate) {
42
- const { sourceCode } = context;
42
+ const reachableWord = reachableCommandWord(context, '[[');
43
43
  return {
44
44
  CallExpr(node) {
45
- const word = reachableCommandWord(sourceCode, node, '[[');
45
+ const word = reachableWord(node);
46
46
  if (word === undefined)
47
47
  return;
48
48
  context.report({ node: word, messageId: 'doubleBracket', data: { target: gate.target } });
@@ -27,8 +27,8 @@ export const noEpochVariables = createCompatRule({
27
27
  description: 'Disallow `$EPOCHSECONDS` and `$EPOCHREALTIME` when the target shell lacks them',
28
28
  },
29
29
  messages: {
30
- epochSeconds: '`$EPOCHSECONDS` needs {{required}}, but this file targets {{target}}, where it is unset. Use `$(date +%s)` instead (GNU, BSD and BusyBox `date`; POSIX `date` has no `%s`).',
31
- epochRealtime: '`$EPOCHREALTIME` needs {{required}}, but this file targets {{target}}, where it is unset. `$(date +%s)` gives whole seconds (GNU, BSD and BusyBox `date`); there is no portable source of the fraction.',
30
+ epochSeconds: '`$EPOCHSECONDS` needs {{required}}, but this file must support {{target}}, where it is unset. Use `$(date +%s)` instead (GNU, BSD and BusyBox `date`; POSIX `date` has no `%s`).',
31
+ epochRealtime: '`$EPOCHREALTIME` needs {{required}}, but this file must support {{target}}, where it is unset. `$(date +%s)` gives whole seconds (GNU, BSD and BusyBox `date`); there is no portable source of the fraction.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -32,7 +32,7 @@ export const noFdVariable = createCompatRule({
32
32
  description: 'Disallow `{varname}` file-descriptor redirections (`exec {fd}>file`) when the target shell lacks them',
33
33
  },
34
34
  messages: {
35
- fdVariable: '`{{{name}}}` descriptor allocation needs {{required}}, but this file targets {{target}}. Use a fixed descriptor number instead, for example `exec 3>file`, then `>&3`, then `exec 3>&-`.',
35
+ fdVariable: '`{{{name}}}` descriptor allocation needs {{required}}, but this file must support {{target}}. Use a fixed descriptor number instead, for example `exec 3>file`, then `>&3`, then `exec 3>&-`.',
36
36
  },
37
37
  },
38
38
  create(context, gate) {
@@ -28,7 +28,7 @@ export const noGlobstar = createCompatRule({
28
28
  description: 'Disallow `shopt -s globstar` when the target shell lacks the option',
29
29
  },
30
30
  messages: {
31
- globstar: "`shopt -s globstar` needs {{required}}, but this file targets {{target}}, where `**` matches like `*`. Search the tree with `find` instead, for example `find dir -name '*.txt'`.",
31
+ globstar: "`shopt -s globstar` needs {{required}}, but this file must support {{target}}, where `**` matches like `*`. Search the tree with `find` instead, for example `find dir -name '*.txt'`.",
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -37,8 +37,8 @@ export const noLastpipeSemantics = createCompatRule({
37
37
  description: 'Disallow `shopt -s lastpipe` when the target shell lacks the option',
38
38
  },
39
39
  messages: {
40
- lastpipe: '`shopt -s lastpipe` needs {{required}}, but this file targets {{target}}. Keep the loop in the current shell by feeding it with process substitution instead, for example `while read -r line; do …; done < <(cmd)`.',
41
- lastpipePosix: '`shopt -s lastpipe` needs {{required}}, but this file targets {{target}}. Process substitution is not POSIX either; keep the loop in the current shell by writing the output to a temporary file and reading that, for example `cmd > "$tmp"` then `while read -r line; do …; done < "$tmp"`, or by feeding the loop a here-document.',
40
+ lastpipe: '`shopt -s lastpipe` needs {{required}}, but this file must support {{target}}. Keep the loop in the current shell by feeding it with process substitution instead, for example `while read -r line; do …; done < <(cmd)`.',
41
+ lastpipePosix: '`shopt -s lastpipe` needs {{required}}, but this file must support {{target}}. Process substitution is not POSIX either; keep the loop in the current shell by writing the output to a temporary file and reading that, for example `cmd > "$tmp"` then `while read -r line; do …; done < "$tmp"`, or by feeding the loop a here-document.',
42
42
  },
43
43
  },
44
44
  create(context, gate) {
@@ -47,9 +47,10 @@ export const noLet = createCompatRule({
47
47
  create(context, gate) {
48
48
  const { sourceCode } = context;
49
49
  const fileDefinesLet = definedFunctionNames(sourceCode).has(LET);
50
+ const reachableWord = reachableCommandWord(context, LET);
50
51
  return {
51
52
  CallExpr(node) {
52
- const word = reachableCommandWord(sourceCode, node, LET);
53
+ const word = reachableWord(node);
53
54
  if (word === undefined)
54
55
  return;
55
56
  // Only a name written directly can run a function: a wrapper skips it.
@@ -28,6 +28,7 @@
28
28
  * all, get the report without a suggestion.
29
29
  */
30
30
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
31
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
31
32
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
32
33
  import { createCompatRule } from './create-compat-rule.js';
33
34
  /** The builtin's two names. */
@@ -84,17 +85,18 @@ export const noMapfile = createCompatRule({
84
85
  description: 'Disallow `mapfile` and `readarray` when the target shell lacks them',
85
86
  },
86
87
  messages: {
87
- mapfile: '`{{command}}` needs {{required}}, but this file targets {{target}}. Read the lines with a `while IFS= read -r` loop instead.',
88
+ mapfile: '`{{command}}` needs {{required}}, but this file must support {{target}}. Read the lines with a `while IFS= read -r` loop instead.',
88
89
  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
  },
90
91
  },
91
92
  create(context, gate) {
92
93
  const { sourceCode } = context;
94
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
93
95
  const arraysAvailable = sourceCode.dialect !== 'posix';
94
96
  return {
95
97
  CallExpr(node) {
96
98
  const invoked = sourceCode.resolveInvokedCommand(node, {
97
- transparentWrappers: TRANSPARENT_WRAPPERS,
99
+ transparentWrappers,
98
100
  });
99
101
  if (invoked?.effective.static !== true)
100
102
  return;
@@ -30,14 +30,14 @@ export const noNameref = createCompatRule({
30
30
  description: 'Disallow namerefs (`declare -n`, `local -n`) when the target shell lacks them',
31
31
  },
32
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.',
33
+ nameref: '`{{command}} -n` needs {{required}}, but this file must support {{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 must support {{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
35
  },
36
36
  },
37
37
  create(context, gate) {
38
38
  const { sourceCode } = context;
39
39
  const messageId = sourceCode.dialect === 'posix' ? 'namerefPosix' : 'nameref';
40
- return declarationFlagVisitor(sourceCode, 'n', (node, command) => {
40
+ return declarationFlagVisitor(context, 'n', (node, command) => {
41
41
  context.report({ node, messageId, data: { ...gate, command } });
42
42
  }, DECLARATION_AND_LOCAL_COMMANDS);
43
43
  },