@eslint-sh/eslint-plugin 0.6.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.
package/README.md CHANGED
@@ -24,7 +24,7 @@ recipes. Four shared configs ship, each supplying `plugins: { sh }` and `languag
24
24
  `sh/max-script-complexity`, with every rule at `error`. Its convention and wrapper rules report nothing until you bind a declaration
25
25
  convention or a wrapper. The [rules index](./docs/rules/README.md) lists all forty built-in rules,
26
26
  their presets, and
27
- autofix availability. The version-gated rules `sh/compat/no-append-all-redirect`, `sh/compat/no-associative-arrays`, `sh/compat/no-case-attributes`, `sh/compat/no-case-fallthrough`, `sh/compat/no-case-modification`, `sh/compat/no-coproc`, `sh/compat/no-declare-g`, `sh/compat/no-epoch-variables`, `sh/compat/no-fd-variable`, `sh/compat/no-globstar`, `sh/compat/no-lastpipe-semantics`, `sh/compat/no-mapfile`, `sh/compat/no-nameref`, `sh/compat/no-negative-array-index`, `sh/compat/no-nounset-empty-array`, `sh/compat/no-parameter-transformation`, `sh/compat/no-pipe-all`, `sh/compat/no-printf-time-format`, `sh/compat/no-test-v`, `sh/compat/no-unicode-escape` and `sh/compat/no-wait-n`, report features your target shell version
27
+ autofix availability. The version-gated rules `sh/compat/no-append-all-redirect`, `sh/compat/no-associative-arrays`, `sh/compat/no-brace-sequence-padding-step`, `sh/compat/no-case-attributes`, `sh/compat/no-case-fallthrough`, `sh/compat/no-case-modification`, `sh/compat/no-coproc`, `sh/compat/no-declare-g`, `sh/compat/no-epoch-variables`, `sh/compat/no-fd-variable`, `sh/compat/no-globstar`, `sh/compat/no-lastpipe-semantics`, `sh/compat/no-mapfile`, `sh/compat/no-nameref`, `sh/compat/no-negative-array-index`, `sh/compat/no-nounset-empty-array`, `sh/compat/no-parameter-transformation`, `sh/compat/no-pipe-all`, `sh/compat/no-printf-time-format`, `sh/compat/no-test-v`, `sh/compat/no-unicode-escape` and `sh/compat/no-wait-n`, report features your target shell version
28
28
  lacks; `configs['bash-3.2']` (`shellVersion: '3.2'`) and `configs['posix-portable']` (`dialect: 'posix'`, with
29
29
  `sh/require-strict-mode` as `set -eu`) enable them for those targets. `configs['posix-portable']` also reports `[[`, `let` and `source`, which fail on dash (`sh/compat/no-double-bracket`, `sh/compat/no-let`, `sh/compat/no-source`), but not `local`, `echo -e` or the other constructs it leaves to shellcheck, so a clean run does not prove a script runs under every POSIX `sh`; see [version compatibility](./docs/compat.md). Run shellcheck alongside eslint-sh: shellcheck catches shell correctness
30
30
  problems; eslint-sh enforces project policy. [Running eslint-sh beside shellcheck](./docs/shellcheck.md)
@@ -36,6 +36,11 @@ Disable directives / inline config are implemented and live: all four
36
36
  interplay work through ESLint's native machinery. The rule id below is illustrative; the
37
37
  directives suppress built-in rules and your own custom rules alike: for a rule you've configured as `sh/your-rule`,
38
38
  `# eslint-disable-next-line sh/your-rule -- justification` suppresses it on the following line.
39
+ A script can therefore switch any rule off for itself. When the scripts you lint come from someone
40
+ who doesn't own the policy, see [linting code you don't control](./docs/untrusted-code.md): a
41
+ config that ignores those comments, and how to run ESLint from outside the checkout under review,
42
+ so that it can't supply the config, ESLint, the plugin, or anything else ESLint looks for in the
43
+ directory it runs in.
39
44
 
40
45
  ## CommonJS config
41
46
 
@@ -13,6 +13,7 @@
13
13
  const bash32Rules = {
14
14
  'sh/compat/no-append-all-redirect': 'error',
15
15
  'sh/compat/no-associative-arrays': 'error',
16
+ 'sh/compat/no-brace-sequence-padding-step': 'error',
16
17
  'sh/compat/no-case-attributes': 'error',
17
18
  'sh/compat/no-case-fallthrough': 'error',
18
19
  'sh/compat/no-case-modification': 'error',
@@ -12,6 +12,7 @@ const posixPortableRules = {
12
12
  'sh/shebang-matches-dialect': 'error',
13
13
  'sh/compat/no-append-all-redirect': 'error',
14
14
  'sh/compat/no-associative-arrays': 'error',
15
+ 'sh/compat/no-brace-sequence-padding-step': 'error',
15
16
  'sh/compat/no-case-attributes': 'error',
16
17
  'sh/compat/no-case-fallthrough': 'error',
17
18
  'sh/compat/no-case-modification': 'error',
@@ -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 targets {{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 targets {{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 targets {{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 targets {{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
+ });
@@ -27,7 +27,7 @@ import type { ShellLanguageOptions } from '../../language/shell-language-options
27
27
  *
28
28
  * @internal
29
29
  */
30
- export type CompatFeature = 'appendAllRedirect' | 'associativeArrays' | 'caseAttributes' | 'caseFallthrough' | 'caseModification' | 'coproc' | 'declareGlobal' | 'doubleBracket' | 'epochVariables' | 'fdVariable' | 'globstar' | 'lastpipe' | 'letCommand' | 'mapfile' | 'nameref' | 'negativeArrayIndex' | 'nounsetEmptyArray' | 'parameterTransformation' | 'parameterTransformationCase' | 'parameterTransformationKeys' | 'parameterTransformationQuotedKeys' | 'pipeAll' | 'printfTimeFormat' | 'sourceCommand' | 'testVariableSet' | 'unicodeEscape' | 'unicodeEscapeLatin1' | 'waitNext';
30
+ export type CompatFeature = 'appendAllRedirect' | 'associativeArrays' | 'braceSequencePaddingStep' | 'caseAttributes' | 'caseFallthrough' | 'caseModification' | 'coproc' | 'declareGlobal' | 'doubleBracket' | 'epochVariables' | 'fdVariable' | 'globstar' | 'lastpipe' | 'letCommand' | 'mapfile' | 'nameref' | 'negativeArrayIndex' | 'nounsetEmptyArray' | 'parameterTransformation' | 'parameterTransformationCase' | 'parameterTransformationKeys' | 'parameterTransformationQuotedKeys' | 'pipeAll' | 'printfTimeFormat' | 'sourceCommand' | 'testVariableSet' | 'unicodeEscape' | 'unicodeEscapeLatin1' | 'waitNext';
31
31
  /**
32
32
  * The two `languageOptions` fields gating reads. Both the raw and the
33
33
  * normalized options satisfy it; an unset `dialect` means `"bash"`, as it
@@ -11,6 +11,14 @@ const FEATURE_GATES = Object.freeze({
11
11
  appendAllRedirect: Object.freeze({ bash: '4.0', posix: false }),
12
12
  /** `declare -A` / `typeset -A`: Bash 4.0 NEWS, "associative arrays". */
13
13
  associativeArrays: Object.freeze({ bash: '4.0', posix: false }),
14
+ /**
15
+ * Zero-padded (`{01..10}`) and stepped (`{1..10..2}`, `{a..e..2}`) brace
16
+ * sequences: Bash 4.0 NEWS (brace expansion gained zero-padding and an
17
+ * increment). Bash 3.2 expands the padded form without the padding and
18
+ * leaves the stepped form as a literal word; POSIX sh has no brace
19
+ * expansion.
20
+ */
21
+ braceSequencePaddingStep: Object.freeze({ bash: '4.0', posix: false }),
14
22
  /**
15
23
  * The case-modifying expansions `${v^}`, `${v^^}`, `${v,}`, `${v,,}`: Bash
16
24
  * 4.0 NEWS, "new case-modifying word expansions".
@@ -1,5 +1,6 @@
1
1
  import { noAppendAllRedirect } from './compat/no-append-all-redirect.js';
2
2
  import { noAssociativeArrays } from './compat/no-associative-arrays.js';
3
+ import { noBraceSequencePaddingStep } from './compat/no-brace-sequence-padding-step.js';
3
4
  import { noCaseAttributes } from './compat/no-case-attributes.js';
4
5
  import { noCaseFallthrough } from './compat/no-case-fallthrough.js';
5
6
  import { noCaseModification } from './compat/no-case-modification.js';
@@ -47,6 +48,7 @@ import { shebangMatchesDialect } from './shebang-matches-dialect.js';
47
48
  export const rules = {
48
49
  'compat/no-append-all-redirect': noAppendAllRedirect,
49
50
  'compat/no-associative-arrays': noAssociativeArrays,
51
+ 'compat/no-brace-sequence-padding-step': noBraceSequencePaddingStep,
50
52
  'compat/no-case-attributes': noCaseAttributes,
51
53
  'compat/no-case-fallthrough': noCaseFallthrough,
52
54
  'compat/no-case-modification': noCaseModification,
@@ -25,6 +25,10 @@
25
25
  * so a dynamic command word is never matched.
26
26
  *
27
27
  * The suggestion wraps the offending statement in `if [ -t 0 ]; then … fi`.
28
+ * It is withheld when another command shares the line that opens the
29
+ * statement's here-document, because the statement's source range runs on to
30
+ * that here-document's terminator and would carry the other command into the
31
+ * guard.
28
32
  */
29
33
  import { DashHdoc, Hdoc } from 'sh-ast/analyze';
30
34
  import { createRule } from './create-rule.js';
@@ -147,6 +151,26 @@ function isControlFlowOperand(stmt, sourceCode) {
147
151
  }
148
152
  /** Redirection operators that open a here-document (`<<`, `<<-`), whose body follows the line. */
149
153
  const HEREDOC_OPERATORS = new Set([Hdoc, DashHdoc]);
154
+ /**
155
+ * Whether other source shares the line that opens one of `stmt`'s
156
+ * here-documents. A here-document's body follows that line, so the statement's
157
+ * range reaches the body's terminator and covers whatever else sits on the
158
+ * opener line (`read x 3<<EOF; echo hi`, a trailing comment): wrapping the
159
+ * range would put that source inside the guard, where it runs only on a
160
+ * terminal. A bare `;` after the opener belongs to the statement and does not
161
+ * count. The opener line is read from the end of the statement's inline
162
+ * tokens (its command, its redirections' operators and words), so the
163
+ * statement's own further redirections on that line are not foreign.
164
+ */
165
+ function sharesHeredocOpenerLine(stmt, text) {
166
+ const heredocs = stmt.redirs.filter((redir) => HEREDOC_OPERATORS.has(redir.op));
167
+ if (heredocs.length === 0)
168
+ return false;
169
+ const inlineEnd = Math.max(...(stmt.cmd === undefined ? [] : [stmt.cmd.range[1]]), ...stmt.redirs.map((redir) => HEREDOC_OPERATORS.has(redir.op) ? (redir.word?.range[1] ?? redir.range[1]) : redir.range[1]));
170
+ const lineEnd = text.indexOf('\n', inlineEnd);
171
+ const tail = text.slice(inlineEnd, lineEnd === -1 ? text.length : lineEnd);
172
+ return !/^[ \t]*;?[ \t]*\r?$/u.test(tail);
173
+ }
150
174
  /**
151
175
  * The text that replaces `stmtText` (a statement starting at `start`) with
152
176
  * the statement inside `if [ -t 0 ]; then … fi`.
@@ -261,7 +285,12 @@ export const requireTtyGuard = createRule({
261
285
  const stmt = sourceCode.getParent(node);
262
286
  // A statement that runs in the background cannot be wrapped without
263
287
  // changing what is backgrounded, so it gets no suggestion.
264
- const wrappable = stmt?.type === 'Stmt' && !stmt.background && !isControlFlowOperand(stmt, sourceCode);
288
+ // A statement sharing its here-document's opener line with another
289
+ // command gets none either (see `sharesHeredocOpenerLine`).
290
+ const wrappable = stmt?.type === 'Stmt' &&
291
+ !stmt.background &&
292
+ !isControlFlowOperand(stmt, sourceCode) &&
293
+ !sharesHeredocOpenerLine(stmt, sourceCode.text);
265
294
  context.report({
266
295
  node,
267
296
  messageId: 'unguardedInteractive',
package/docs/compat.md CHANGED
@@ -31,6 +31,7 @@ fi
31
31
  | ---- | ------- | ---- | -------- |
32
32
  | [`sh/compat/no-append-all-redirect`](./rules/compat/no-append-all-redirect.md) | `&>>` | 4.0 | no |
33
33
  | [`sh/compat/no-associative-arrays`](./rules/compat/no-associative-arrays.md) | `declare -A`, `typeset -A` | 4.0 | no |
34
+ | [`sh/compat/no-brace-sequence-padding-step`](./rules/compat/no-brace-sequence-padding-step.md) | `{01..10}`, `{1..10..2}`, `{a..e..2}` | 4.0 | no |
34
35
  | [`sh/compat/no-case-attributes`](./rules/compat/no-case-attributes.md) | `declare -l`, `declare -u` (and `typeset`, `local`) | 4.0 | no |
35
36
  | [`sh/compat/no-case-fallthrough`](./rules/compat/no-case-fallthrough.md) | `;&`, `;;&` in `case` | 4.0 | no |
36
37
  | [`sh/compat/no-case-modification`](./rules/compat/no-case-modification.md) | `${v^}`, `${v^^}`, `${v,}`, `${v,,}` | 4.0 | no |
@@ -63,9 +64,9 @@ reports each operator against its own release. `sh/compat/no-unicode-escape` nee
63
64
  points but 4.3 for U+0080 to U+00FF, because the Bash 4.2 release writes those as a single Latin-1 byte
64
65
  instead of UTF-8. `sh/compat/no-nounset-empty-array` gates a behavior, not syntax: every Bash has
65
66
  `"${a[@]}"`, but through 4.3 an empty array aborts the script under `set -u`. `every` means every Bash release has the feature, so only
66
- `dialect: "posix"` can lack it: those three are the POSIX-only gates, and the other twenty-one are
67
+ `dialect: "posix"` can lack it: those three are the POSIX-only gates, and the other twenty-two are
67
68
  the version gates. The `bash-3.2` preset enables the version gates and the `posix-portable` preset
68
- enables all twenty-four; see
69
+ enables all twenty-five; see
69
70
  [Presets](#presets).
70
71
 
71
72
  ## Setting the target
@@ -112,13 +113,13 @@ Two presets name a deployment target and enable every rule that enforces it, so
112
113
  the rules yourself:
113
114
 
114
115
  - **`sh.configs['bash-3.2']`** sets `languageOptions: { dialect: 'bash', shellVersion: '3.2' }`
115
- and enables all twenty-one version-gated `compat/*` rules at `error`. Use it for scripts that must run on the Bash
116
+ and enables all twenty-two version-gated `compat/*` rules at `error`. Use it for scripts that must run on the Bash
116
117
  3.2 that macOS ships as `/bin/bash`.
117
118
  - **`sh.configs['posix-portable']`** reports the Bash-only constructs in the table above, which
118
119
  fail or read as unset on dash, but not `local`, `echo -e` or the others listed under
119
120
  [What `posix-portable` does not report](#what-posix-portable-does-not-report), so a clean run does
120
121
  not prove a script runs under every POSIX `sh`. It sets `languageOptions: { dialect: 'posix' }` and
121
- enables all twenty-four `compat/*` rules, `sh/shebang-matches-dialect`, and
122
+ enables all twenty-five `compat/*` rules, `sh/shebang-matches-dialect`, and
122
123
  `sh/require-strict-mode` configured as `set -eu`, all at `error`. Strict mode is `set -eu` and not
123
124
  `set -euo pipefail` because `pipefail` joined POSIX only in POSIX.1-2024, and older `dash` and
124
125
  BusyBox `ash` reject it, so a `pipefail` requirement would make the autofix break a working
@@ -205,10 +206,6 @@ suggestion that you apply by choice. Each rule's page says when it offers one.
205
206
 
206
207
  ## Not covered
207
208
 
208
- - **Zero-padded and stepped brace sequences** (`{01..10}`, `{1..10..2}`, Bash 4.0). Bash 3.2 drops
209
- the padding and leaves the stepped form unexpanded, with no error. The parser keeps a brace
210
- expansion as part of a word's literal text and gives it no node of its own, so a rule could find
211
- one only by matching text, which would also match literal braces. No rule reports it.
212
209
  - **Bash syntax that the POSIX parser rejects.** Under `dialect: "posix"`, `|&`, `&>>`, `;&`, `;;&`,
213
210
  `{fd}` redirections, `${v@op}` and arrays are parse errors, so a POSIX file that uses them is
214
211
  reported as unparsable, and the rules for those features never run on it. `$'…'` is read as a
@@ -236,6 +233,8 @@ the script. The `dash` column is the evidence for the POSIX sh column.
236
233
  | `associative-arrays-alt-jq` | the message's `jq -r --arg k "$key" '.[$k]' map.json` lookup (needs `jq` on PATH) | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
237
234
  | `associative-arrays-typeset` | `typeset -A m` keeps two string keys apart | no | yes | yes | yes | yes | yes | yes | yes | yes | yes | no |
238
235
  | `associative-arrays` | `declare -A m` keeps two string keys apart | no | yes | yes | yes | yes | yes | yes | yes | yes | yes | no |
236
+ | `brace-pad-step` | zero-padded `{01..03}` and stepped `{1..9..4}` brace expansion | no | yes | yes | yes | yes | yes | yes | yes | yes | yes | no |
237
+ | `brace-sequence-alt-loop` | the messages' counting loops, the padded one with `printf '%02d\n'` and the stepped one by `i + 4`, each written exactly as printed | not run | not run | not run | not run | not run | not run | not run | not run | not run | not run | not run |
239
238
  | `case-attributes-alt-tr` | the message's `$(printf '%s' "$v" \| tr '[:upper:]' '[:lower:]')` and its upper-case twin | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
240
239
  | `case-fallthrough-alt` | the message's rewrites: the next branch's commands repeated for `;&`, and a second `case` for `;;&` | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
241
240
  | `case-fallthrough` | `;&` runs the next branch without testing its pattern | no | yes | yes | yes | yes | yes | yes | yes | yes | yes | no |
@@ -331,6 +330,14 @@ How to read it:
331
330
  which a minimum-version gate cannot express, so the rule does not report them.
332
331
  - **Negative subscripts.** Bash 4.2 reads `${a[-1]}`, but rejects `${#a[-1]}`, `a[-1]=Z` and
333
332
  `unset 'a[-1]'` with `bad array subscript`. 4.3 is the first release where every form works.
333
+ - **Brace sequences.** `brace-pad-step` is "no" on Bash 3.2, which prints `1 2 3 {1..9..4}` with
334
+ no error, and on dash, which prints `{01..03} {1..9..4}`; it is "yes" from 4.0. That row records
335
+ the `run.sh` result reported in issue #237; the probe was also re-run on `/bin/bash` (3.2.57),
336
+ Homebrew's Bash 5.3.9 and the system `dash`, with the same outcomes. The
337
+ `brace-sequence-alt-loop` row has not been run through `run.sh`: its probe was run only on macOS's
338
+ `/bin/bash` (3.2.57), Homebrew's Bash 5.3.9 and the system `dash`, which are distribution builds,
339
+ not the tarball releases in the other columns, and each printed the expected line. Fill the row by
340
+ running `scripts/compat-matrix/run.sh`.
334
341
  - **Alternatives.** Every alternative a message recommends for a Bash target works on Bash 3.2,
335
342
  except `param-transform-alt-case-operators` (`"${v^^}"` and its twins), which the
336
343
  `no-parameter-transformation` message offers only for a target from 4.0 to 5.0, where it works.
@@ -12,6 +12,7 @@ See the [canonical quickstart](https://github.com/mike-north/eslint-sh/blob/main
12
12
  | ---- | ------- | ------- | ----------- | ------- |
13
13
  | [`sh/compat/no-append-all-redirect`](./compat/no-append-all-redirect.md) | `bash-3.2`, `posix-portable` | No | Yes | Disallow the `&>>` redirection when the target shell lacks it, suggesting `>> file 2>&1`. |
14
14
  | [`sh/compat/no-associative-arrays`](./compat/no-associative-arrays.md) | `bash-3.2`, `posix-portable` | No | No | Disallow `declare -A` / `typeset -A` when the target shell lacks associative arrays. |
15
+ | [`sh/compat/no-brace-sequence-padding-step`](./compat/no-brace-sequence-padding-step.md) | `bash-3.2`, `posix-portable` | No | No | Disallow zero-padded (`{01..10}`) and stepped (`{1..10..2}`) brace sequences when the target shell lacks them, pointing at a counting loop. |
15
16
  | [`sh/compat/no-case-attributes`](./compat/no-case-attributes.md) | `bash-3.2`, `posix-portable` | No | No | Disallow `declare -l` / `declare -u` (and `typeset`, `local`) when the target shell lacks them, pointing at `tr`. |
16
17
  | [`sh/compat/no-case-fallthrough`](./compat/no-case-fallthrough.md) | `bash-3.2`, `posix-portable` | No | No | Disallow the `case` terminators `;&` and `;;&` when the target shell lacks them. |
17
18
  | [`sh/compat/no-case-modification`](./compat/no-case-modification.md) | `bash-3.2`, `posix-portable` | No | No | Disallow `${v^^}`, `${v,,}` and variants when the target shell lacks them, pointing at `tr`. |
@@ -58,7 +59,7 @@ offer editor suggestions, which you apply one at a time from your editor; the Au
58
59
  describes edits available through `eslint --fix`. `quote-expansions` suggests quoting instead of
59
60
  fixing because only the script's author knows whether the splitting was intended.
60
61
 
61
- The version-gated `compat/*` rules (`compat/no-append-all-redirect`, `compat/no-associative-arrays`, `compat/no-case-attributes`, `compat/no-case-fallthrough`, `compat/no-case-modification`, `compat/no-coproc`, `compat/no-declare-g`, `compat/no-epoch-variables`, `compat/no-fd-variable`, `compat/no-globstar`, `compat/no-lastpipe-semantics`, `compat/no-mapfile`, `compat/no-nameref`, `compat/no-negative-array-index`, `compat/no-nounset-empty-array`, `compat/no-parameter-transformation`, `compat/no-pipe-all`, `compat/no-printf-time-format`, `compat/no-test-v`, `compat/no-unicode-escape`, `compat/no-wait-n`) report
62
+ The version-gated `compat/*` rules (`compat/no-append-all-redirect`, `compat/no-associative-arrays`, `compat/no-brace-sequence-padding-step`, `compat/no-case-attributes`, `compat/no-case-fallthrough`, `compat/no-case-modification`, `compat/no-coproc`, `compat/no-declare-g`, `compat/no-epoch-variables`, `compat/no-fd-variable`, `compat/no-globstar`, `compat/no-lastpipe-semantics`, `compat/no-mapfile`, `compat/no-nameref`, `compat/no-negative-array-index`, `compat/no-nounset-empty-array`, `compat/no-parameter-transformation`, `compat/no-pipe-all`, `compat/no-printf-time-format`, `compat/no-test-v`, `compat/no-unicode-escape`, `compat/no-wait-n`) report
62
63
  shell features that your target shell lacks, as set by `languageOptions.shellVersion` and
63
64
  `dialect`. They report nothing when `shellVersion` is unset, so the `bash-3.2` preset sets it to `3.2`
64
65
  and the `posix-portable` preset sets `dialect: "posix"`; each enables all of them.
@@ -0,0 +1,90 @@
1
+ # `sh/compat/no-brace-sequence-padding-step`
2
+
3
+ Disallow zero-padded (`{01..10}`) and stepped (`{1..10..2}`, `{a..e..2}`) brace sequences in scripts
4
+ whose target shell lacks them: Bash older than 4.0, including the Bash 3.2 that macOS ships as
5
+ `/bin/bash`, and POSIX sh.
6
+
7
+ | Configs | Fixable | Languages |
8
+ | ---------------------------- | ----------- | --------- |
9
+ | `bash-3.2`, `posix-portable` | not fixable | `sh/sh` |
10
+
11
+ ## Rule details
12
+
13
+ Both forms arrived in Bash 4.0, and neither fails on Bash 3.2, which is what makes them easy to miss. On Bash 3.2.57, `echo {01..03} {1..9..4}` prints `1 2 3 {1..9..4}` and exits 0: the padding is dropped and the stepped sequence stays as a literal word. The script carries on with different words. POSIX does not specify brace expansion, so what a POSIX target does with these words depends on the shell: dash prints `{01..03} {1..9..4}`, macOS's `/bin/sh` prints `1 2 3` for `{01..03}`, and Bash 5.3 under `--posix` prints `01 02 03`. A plain `{1..3}` is as shell-dependent, but this rule reports only the two forms Bash 4.0 added.
14
+
15
+ The parser keeps a brace sequence as literal text inside a word, so the rule reads the literal text of the words the shell brace-expands. It reports a `{…}` that holds nothing but a sequence:
16
+
17
+ - two integer endpoints (an optional sign and digits) or two single ASCII letters, with an optional third `..` field that is an integer step. A step makes the sequence stepped, for numbers and letters alike, and the step may be `0` or negative.
18
+ - integer endpoints are padded when either has more than one digit and starts with `0` after an optional `-`: `{01..10}`, `{1..03}`, `{-01..1}`, `{00..10}`. A lone `0` (`{0..3}`) and `{-0..2}` are not padded, and neither is a `+01` endpoint, which Bash 5.3 expands without padding, as Bash 3.2 does.
19
+
20
+ Everything else is left alone, because it is the same on every Bash or is not a sequence at all: `{1..3}`, `{a..c}`, a descending `{3..1}`, a comma list (`{01,02}`, `{01..03,x}`), mixed endpoints (`{a..5..2}`), multi-character endpoints, a variable endpoint (`{01..$n}`, which the shell never expands), and a malformed one (`{01..}`, `{1..9..x}`, spaces inside). Quoting and escaping are honoured: `"{01..10}"`, `'{01..10}'`, `$'{01..10}'`, `\{01..10}` and `{01..10\}` are plain text.
21
+
22
+ Only code the parser sees is read: a sequence inside a quoted string that is run later (`eval '...'`, `trap '...'`) is not seen. A sequence is read where the shell brace-expands: command words and arguments, `for` and `select` lists, the elements of an array literal without an index (`a=({01..03})`), and the operands of `declare`, `local`, `export` and `readonly` (also the value in `declare d={01..03}`). It is not read where the shell does no brace expansion: a plain assignment (`a={01..03}`), a `case` subject or pattern, the operands of `[[ ]]`, here-strings and here-documents, parameter-expansion words (`${v:-{01..03}}`), and a keyed array element (`a=([5]={01..03})`). A redirection target (`: > out{01..01}`) is read only when the sequence provably yields one word. With several words the target is an "ambiguous redirect" error on Bash 5.3 (and for a padded sequence on 3.2 too), so the script is broken on every Bash that has the feature and there is nothing to port. With one word the shells disagree silently: `out{01..01}` writes `out1` on Bash 3.2.57 and `out01` on 5.3.9, and `out{1..3..5}` writes a file literally named `out{1..3..5}` on 3.2.57 and `out1` on 5.3.9. A sequence is always static text, so its length is always known; a target that also holds another brace group (`out{01..01}{a,b}`) is not read, because the product cannot be counted here, so a redirect target with more than one brace group is skipped. The same selection applies under `dialect: "posix"`, although the system `dash` creates a literal file such as `out{01..02}` for any of them without an error: a several-word target is still a sequence that no Bash accepts. A here-string or here-document word is never read. A descriptor duplication whose whole target is a one-word sequence is read only where the shells differ (checked on 3.2.57 and 5.3.9). A padded non-negative number names the same descriptor on both (`>&{01..01}`, `2>&{02..02}`, `<&{00..00}`, and `>&{-00..-00}`, which is 0), so it is not reported. A negative number in a descriptor position (`2>&{-01..-01}`, `<&{-01..-01}`, `3>&{-01..-01}`) is an "ambiguous redirect" error on both, so it is not reported either. A stepped sequence still differs (`>&{1..3..5}`, `3>&{1..3..5}` and `<&{0..0..2}` are an error or a literal word on 3.2.57 and descriptor 1 or 0 on 5.3.9), and a bare `>&` with a negative number writes a file named `-1` on 3.2.57 and `-01` on 5.3.9, as does `&>{-01..-01}`, so those are reported. The report is on the whole word, once per word, for the first sequence in it.
23
+
24
+ Numbers are bounded the way Bash 5.3 bounds them, because a word outside the bounds stays literal on every Bash and nothing differs: each endpoint and the distance between them must fit a signed 64-bit integer, and so must the step's magnitude (a step of `-9223372036854775808` is not a step). Leading zeros do not count towards the value, so `{1..00000000000000000003}` is a sequence. `{1..3..999999999999999999999}`, `{1..99999999999999999999}` and `{-1..9223372036854775807..9223372036854775807}` are not reported. These bounds were measured on Bash 5.3.9 and 3.2.57 only; the 4.x releases' bounds were not measured.
25
+
26
+ The portable alternative is to count in a loop and format with `printf`, for example `i=1; while [ "$i" -le 3 ]; do printf '%02d\n' "$i"; i=$((i + 1)); done` for a padded sequence, or `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done` for a stepped one. Both run on Bash 3.2 and on dash.
27
+
28
+ This is a version-compat rule. Whether it reports depends only on `languageOptions`, as
29
+ described in [version compatibility](../../compat.md):
30
+
31
+ - `shellVersion` unset: never reports. An unset version means the newest Bash.
32
+ - `dialect: "bash"` (the default) or `"bats"`: reports when `shellVersion` is older than `4.0`.
33
+ - `dialect: "posix"`: reports at any `shellVersion`, because POSIX does not specify brace expansion.
34
+ - `dialect: "mksh"` or `"zsh"`: never reports.
35
+
36
+ ## Options
37
+
38
+ This rule has no options. Set the target through `languageOptions`:
39
+
40
+ ```js
41
+ // eslint.config.js
42
+ {
43
+ languageOptions: { dialect: 'bash', shellVersion: '3.2' },
44
+ rules: {
45
+ 'sh/compat/no-brace-sequence-padding-step': 'error',
46
+ },
47
+ }
48
+ ```
49
+
50
+ ## Examples
51
+
52
+ ### Incorrect
53
+
54
+ With `shellVersion: "3.2"`:
55
+
56
+ <!-- example: {"dialect": "bash", "shellVersion": "3.2", "findings": 3} -->
57
+
58
+ ```sh
59
+ for n in {01..12}; do echo "report-$n"; done
60
+ echo {1..9..4}
61
+ cp backup{a..e..2}.tar /tmp
62
+ ```
63
+
64
+ ### Correct
65
+
66
+ With `shellVersion: "3.2"`:
67
+
68
+ <!-- example: {"dialect": "bash", "shellVersion": "3.2"} -->
69
+
70
+ ```sh
71
+ n=1
72
+ while [ "$n" -le 12 ]; do printf 'report-%02d\n' "$n"; n=$((n + 1)); done
73
+ echo {1..3} {a..c}
74
+ echo '{01..12}'
75
+ a={01..03}
76
+ ```
77
+
78
+ With `shellVersion: "4.0"` or newer, or with no `shellVersion`, both forms are fine.
79
+
80
+ ## When not to use it
81
+
82
+ Turn the rule off when your scripts never run on Bash older than 4.0, or leave `shellVersion`
83
+ unset, which makes the rule report nothing.
84
+
85
+ ## Relationship to shellcheck
86
+
87
+ eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
88
+ layer. Run shellcheck beside it.
89
+
90
+ shellcheck 0.11.0 reports SC3009 (brace expansion is undefined in POSIX sh) under `-s sh` for every brace expansion, a plain `{1..3}` and `{a,b}` included. Under `-s bash` it reports nothing for these sequences. It does not know which Bash release a script must run on, so it cannot report the padded and stepped forms for a Bash 3.2 target. This rule exists for that target.
@@ -82,7 +82,10 @@ if [ -t 0 ]; then read name; fi
82
82
  A multi-line statement that starts its own line is wrapped on lines of its own. Anywhere else the
83
83
  wrapper goes around it in place, because an earlier command on the line may have opened a
84
84
  here-document whose body begins at the next line break; a line break goes before `fi` only when the
85
- statement ends in a here-document of its own. A statement run in the background (`read x &`)
85
+ statement ends in a here-document of its own. A statement whose here-document opener shares its
86
+ line with another command (`read x 3<<EOF; echo hi`, or a trailing comment) gets no suggestion: the
87
+ statement's source runs on to the terminator, so wrapping it would pull that command into the guard
88
+ and it would run only on a terminal. A statement run in the background (`read x &`)
86
89
  gets no suggestion, because wrapping it would change what is backgrounded. A statement whose exit
87
90
  status steers control flow (an `if`/`while`/`until` condition, or an operand of `&&` or `||`) is
88
91
  reported without a suggestion too: the wrapper would exit 0 when there is no terminal, which
@@ -78,6 +78,7 @@ reason given, and each rule's page has a "Relationship to shellcheck" section. T
78
78
  | [`sh/compat/no-double-bracket`](./rules/compat/no-double-bracket.md), [`no-let`](./rules/compat/no-let.md), [`no-source`](./rules/compat/no-source.md) | SC3010 (`[[`), SC3039 (`let`), SC3046 (`source`), under `-s sh` only | Under `-s bash` shellcheck reports nothing for `[[` or `source`, and for `let` only SC2219, a style note that prefers `(( ))`. These rules report them for the dialect you configure, because they fail on dash and would otherwise defeat a preset named for portability. |
79
79
  | [`sh/compat/no-test-v`](./rules/compat/no-test-v.md), [`no-wait-n`](./rules/compat/no-wait-n.md), [`no-globstar`](./rules/compat/no-globstar.md), [`no-pipe-all`](./rules/compat/no-pipe-all.md), [`no-fd-variable`](./rules/compat/no-fd-variable.md), [`no-unicode-escape`](./rules/compat/no-unicode-escape.md) | SC3016 (`test -v`, with SC3010 for `[[`), SC3045 (`wait -n`), SC3044 (`shopt`), SC3029 (`\|&`), SC3022 (`{fd}`), SC3003 (`$'…'`) | shellcheck reports these Bash features only for scripts it treats as POSIX `sh`. These rules report them for a Bash version you name. |
80
80
  | [`sh/compat/no-nameref`](./rules/compat/no-nameref.md), [`no-case-attributes`](./rules/compat/no-case-attributes.md) | SC3043 (`local`), SC3044 (`declare`, `typeset`): the command, not the `-n`, `-l` or `-u` option | Under `-s sh` shellcheck reports the command that carries the option; under `-s bash` it reports nothing for either. These rules report the option for a Bash version you name. |
81
+ | [`sh/compat/no-brace-sequence-padding-step`](./rules/compat/no-brace-sequence-padding-step.md) | SC3009 (brace expansion, under `-s sh` only) | Under `-s sh` shellcheck reports every brace expansion, a plain `{1..3}` and `{a,b}` too, as undefined in POSIX sh. Under `-s bash` it reports nothing, so a Bash older than 4.0 gets no finding for the padded and stepped forms. The rule reports only those two forms, for a Bash version you name. |
81
82
  | [`sh/compat/no-case-fallthrough`](./rules/compat/no-case-fallthrough.md) | SC2127 (an error, not an SC3xxx check) | Under `-s sh` shellcheck asks for a bash or ksh shebang; under `-s bash` it reports nothing. The rule reports `;&` and `;;&` for a Bash version you name. |
82
83
  | [`sh/compat/no-epoch-variables`](./rules/compat/no-epoch-variables.md) | SC3028 (`$EPOCHSECONDS`, `$EPOCHREALTIME`, under `-s sh` only) | Under `-s bash` shellcheck reports nothing, so a Bash older than 5.0 gets no finding. The rule reports them for a Bash version you name. |
83
84
  | [`sh/compat/no-append-all-redirect`](./rules/compat/no-append-all-redirect.md), [`no-parameter-transformation`](./rules/compat/no-parameter-transformation.md), [`no-printf-time-format`](./rules/compat/no-printf-time-format.md), [`no-nounset-empty-array`](./rules/compat/no-nounset-empty-array.md) | none | shellcheck reports nothing for `&>>`, `${v@op}`, `printf '%(…)T'` or an empty array under `set -u`, under `-s sh` or `-s bash` (it reports only the array syntax, SC3030 and SC3054, under `-s sh`). |
@@ -0,0 +1,440 @@
1
+ # Linting code you don't control
2
+
3
+ eslint-sh is often a policy gate: CI lints a pull request, an agent's output, or a vendored
4
+ repository whose author does not own the policy. In that setting, the author controls the scripts
5
+ under review and every other file in the checkout. This page covers what that lets them change, how
6
+ to run the lint from a directory outside those files, so that they can't change its configuration
7
+ or its exit code, and what a passing run still doesn't tell you:
8
+
9
+ - [What a script's comments can switch off](#what-a-scripts-comments-can-switch-off)
10
+ - [A policy config that ignores them](#a-policy-config-that-ignores-them)
11
+ - [Run ESLint from outside the checkout](#run-eslint-from-outside-the-checkout)
12
+ - [Declaration conventions that read files](#declaration-conventions-that-read-files)
13
+ - [Which files get linted](#which-files-get-linted)
14
+ - [What this does not cover](#what-this-does-not-cover)
15
+
16
+ Everything below was run against ESLint 10.2.0, the oldest version this plugin's peer range allows,
17
+ and against 10.7.0.
18
+
19
+ ## What a script's comments can switch off
20
+
21
+ ESLint's inline comments work in shell scripts the same way they do in JavaScript, with `#` as the
22
+ comment marker. With the default `linterOptions`, a script can silence any rule for itself, and
23
+ neither `sh/recommended` nor `sh/agent-authored` changes that. Each of these scripts lints with no
24
+ `sh/no-eval` report under `sh.configs.recommended`:
25
+
26
+ ```sh
27
+ #!/bin/bash
28
+ # eslint-disable-next-line sh/no-eval
29
+ eval "$x"
30
+ ```
31
+
32
+ | Comment | Effect |
33
+ | -------------------------------------------- | -------------------------------------------- |
34
+ | `# eslint-disable-next-line sh/no-eval` | Silences the rule on the next line. |
35
+ | `eval "$x" # eslint-disable-line sh/no-eval` | Silences the rule on its own line. |
36
+ | `# eslint-disable` … `# eslint-enable` | Silences every rule between the two. |
37
+ | `# eslint sh/no-eval: off` | Turns the rule off for the whole file. |
38
+
39
+ The `eslint` prefix is the default `languageOptions.directivePrefix`. A config that sets it, for
40
+ example to `shellcheck-sh`, makes `# shellcheck-sh-disable-next-line sh/no-eval` the working form
41
+ and turns `# eslint-disable-next-line` into an ordinary comment. The prefix is chosen by the config,
42
+ not by the script, and it does not stop directives, it only renames them.
43
+
44
+ ## A policy config that ignores them
45
+
46
+ Set `linterOptions.noInlineConfig` in the config you lint with. The `globalIgnores` line and the
47
+ `checkout/` in `files` belong to [the recipe](#run-eslint-from-outside-the-checkout), which explains
48
+ them.
49
+
50
+ ```js
51
+ // policy/eslint.config.mjs
52
+ import { defineConfig, globalIgnores } from 'eslint/config';
53
+ import sh from '@eslint-sh/eslint-plugin';
54
+
55
+ export default defineConfig([
56
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
57
+ {
58
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
59
+ extends: [sh.configs.recommended],
60
+ linterOptions: { noInlineConfig: true },
61
+ },
62
+ ]);
63
+ ```
64
+
65
+ `noInlineConfig: true` makes ESLint ignore every comment in the table above, whatever the
66
+ `directivePrefix`. The rule reports as if the comment were not there, and ESLint adds a warning with
67
+ no rule id at the comment itself. For the script above, with its other report:
68
+
69
+ ```text
70
+ 1:1 error Enable strict mode with `set -euo pipefail` within the first 5 statements; not enabled: errexit, nounset, pipefail sh/require-strict-mode
71
+ 2:1 warning '# eslint-disable-next-line sh/no-eval' has no effect because you have 'noInlineConfig' setting in your config
72
+ 3:1 error `eval` runs text as shell code at run time, so the code it executes cannot be statically reviewed. Write the commands directly instead sh/no-eval
73
+ ```
74
+
75
+ The plugin returns its directives to ESLint through the language's `getDisableDirectives` and
76
+ `getInlineConfigNodes` hooks, and ESLint applies `noInlineConfig` to them as it does to
77
+ JavaScript comments. Under `--max-warnings 0`, that warning fails the
78
+ run on its own: a contributed script that contains any directive fails the gate, even one that would
79
+ have suppressed nothing. If you would rather let those through, run without `--max-warnings 0` and
80
+ read the warnings, but then no other warning fails the run either (see the
81
+ [continuous integration exit codes](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration)).
82
+
83
+ **If you accept directives instead.** `linterOptions.reportUnusedDisableDirectives` sets the
84
+ severity of a disable comment that suppressed nothing. ESLint 10's default is `"warn"`; `"error"`
85
+ fails the run on one:
86
+
87
+ ```js
88
+ // policy/eslint.config.mjs, directives allowed
89
+ import { defineConfig, globalIgnores } from 'eslint/config';
90
+ import sh from '@eslint-sh/eslint-plugin';
91
+
92
+ export default defineConfig([
93
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
94
+ {
95
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
96
+ extends: [sh.configs.recommended],
97
+ linterOptions: { reportUnusedDisableDirectives: 'error' },
98
+ },
99
+ ]);
100
+ ```
101
+
102
+ This keeps stale directives out of the tree, but it is not a gate: the `# eslint-disable-next-line
103
+ sh/no-eval` above still silences `sh/no-eval`, because it did suppress something. Setting both
104
+ options is allowed, but `reportUnusedDisableDirectives` then reports nothing, because
105
+ `noInlineConfig` has already discarded every directive.
106
+
107
+ ## Run ESLint from outside the checkout
108
+
109
+ `linterOptions` only helps if the contributor can't replace the config, ESLint or the plugin, and
110
+ can't change the result some other way. ESLint finds several of its inputs in the directory it runs
111
+ in, and Node looks through that directory's parents for packages. When that directory is the
112
+ checkout, every one of these belongs to the contributor:
113
+
114
+ - **The config.** `eslint .` without `--config` imports the `eslint.config.mjs` in the directory it
115
+ runs in, as JavaScript, with your job's environment variables. ESLint 10 also loads an
116
+ `eslint.config.*` from a subdirectory for the files under it, so a nested config in the checkout
117
+ runs, and replaces your policy for its directory, even if your config sits at the root.
118
+ - **Where the config's imports resolve.** `import sh from '@eslint-sh/eslint-plugin'` resolves
119
+ from the config file's own location. A copy of your config placed inside the checkout imports
120
+ the checkout's `node_modules`.
121
+ - **Which ESLint runs.** `npx eslint` in the checkout runs the checkout's
122
+ `node_modules/.bin/eslint`.
123
+ - **The formatter.** ESLint resolves a formatter name, including the default `stylish`, as the
124
+ package `eslint-formatter-stylish` from the directory it runs in and then from each of its parents,
125
+ with or without `--config`. That package is code in your job. It can print what it likes and set
126
+ the exit code.
127
+ - **The suppressions file.** ESLint applies `eslint-suppressions.json` from the directory it runs in
128
+ whenever that file exists, with no flag, and neither `--config` nor `noInlineConfig` affects it.
129
+ An error listed in the file is hidden, and the run exits 0.
130
+ - **The cache file.** With `--cache`, ESLint reads `.eslintcache` from the directory it runs in and
131
+ reuses the results recorded there. The default `--cache-strategy metadata` trusts a recorded size
132
+ and modification time. `--cache-strategy content` doesn't help either, because the hash it
133
+ compares against is in the same file.
134
+ - **Your job's own relative paths.** A relative entry in `PATH`, or a relative file in
135
+ `NODE_OPTIONS`, resolves against the directory ESLint runs in, so either can run a file from the
136
+ checkout. A version manager may read its settings from there too: `nodenv` runs ESLint under the
137
+ Node version named in a `.node-version` in the checkout or any parent. Other version managers were
138
+ not tested.
139
+
140
+ Installing the checkout is a separate hazard: `npm ci` and `npm install` run the package's lifecycle
141
+ scripts, such as `preinstall`. The lint needs none of the checkout's dependencies, so don't install
142
+ them.
143
+
144
+ The recipe is to run ESLint somewhere else and pass the checkout as an argument. Keep the policy
145
+ where the contributor can't change it in the same change, such as a separate repository or a path
146
+ your CI provides, with `eslint` and `@eslint-sh/eslint-plugin` installed there from its own lockfile.
147
+ If `policy` is a link to such a path, the rule below applies to the parents of its real location
148
+ too, and the guards don't look there.
149
+ Put it beside the checkout, in a directory your job creates and the contributor can't write to.
150
+ Neither that directory nor any of its parents may be inside the contributor's files: a CI job often
151
+ starts in a clone of the repository under review, so don't create it there. The recipe refuses a
152
+ `node_modules` in a parent, but it has no guard for the rest of that rule, such as a `.node-version`
153
+ there.
154
+
155
+ ```text
156
+ job-directory/
157
+ policy/ eslint.config.mjs and its own node_modules
158
+ checkout/ the code under review
159
+ ```
160
+
161
+ Create `checkout` yourself, so that nothing of the contributor's lands beside it. From a repository:
162
+
163
+ ```sh
164
+ git clone URL checkout
165
+ ```
166
+
167
+ From a tarball, keep the input archive outside `job-directory` (here, `../x.tar`) and unpack only
168
+ into `checkout`. Keep every path as archived: the policy covers files directly inside any `bin/`
169
+ directory under `checkout`, so wrappers and siblings need no special handling.
170
+
171
+ ```sh
172
+ [ ! -e checkout ] && [ ! -L checkout ] || { printf 'checkout already exists\n' >&2; exit 1; }
173
+ mkdir checkout && tar -xf ../x.tar -C checkout || exit 1
174
+ ```
175
+
176
+ Stop the job if this fails: a failed `tar` can leave only some of the files. Don't strip path
177
+ components or move files out of a wrapper; stripping can silently discard top-level files.
178
+
179
+ Then, from `job-directory`, not from inside the checkout:
180
+
181
+ ```sh
182
+ [ "$(ls -A | wc -l)" -eq 2 ] && [ -d policy ] && [ -d checkout ] || {
183
+ printf 'the job directory must hold only checkout and policy\n' >&2
184
+ exit 1
185
+ }
186
+ dir=$(pwd -P)
187
+ while :; do
188
+ if [ -e "$dir/node_modules" ]; then
189
+ printf 'node_modules in or above the job directory: %s\n' "$dir" >&2
190
+ exit 1
191
+ fi
192
+ [ "$dir" != / ] || break
193
+ dir=$(dirname "$dir")
194
+ done
195
+ policy/node_modules/.bin/eslint \
196
+ --config policy/eslint.config.mjs \
197
+ --max-warnings 0 \
198
+ checkout
199
+ ```
200
+
201
+ - The working directory is what closes the list above. `eslint-suppressions.json`, `.eslintcache`
202
+ and any relative path in your job's environment resolve in `job-directory`, the formatter name
203
+ resolves there and in its parents, and the checkout's copies are never consulted.
204
+ - The first guard refuses anything else in `job-directory`, because ESLint reads its inputs from
205
+ there: a tarball unpacked here rather than as above can leave an `eslint-suppressions.json` beside
206
+ the checkout. It can't catch such a tarball writing into `policy/`, which only unpacking elsewhere
207
+ prevents.
208
+ - The second guard refuses a `node_modules` in `job-directory` or any parent, because Node looks
209
+ there for the formatter and for a package ESLint's own dependencies ask for on every run, whatever
210
+ `--format` says, and a package found there runs in your job and can set the exit code. If it
211
+ refuses a `node_modules` of your own, such as one in your home directory, make the job directory
212
+ with `mktemp -d` instead.
213
+ - `--config` loads only the policy, and its imports resolve from `policy/node_modules`.
214
+ - The checkout has to be under the working directory. With `--config`, ESLint matches the config's
215
+ `files` and `ignores` against the directory it runs in and treats anything outside it as ignored:
216
+ running from inside `policy/` with `../checkout`, or from elsewhere with an absolute path, exits 2
217
+ with `all of the files matching the glob pattern ... are ignored`. The policy's
218
+ `checkout/**/bin/?*` matches files directly in a `bin` at any depth under `checkout`.
219
+ - `!**/node_modules/` in `globalIgnores` turns off ESLint's default ignore of `**/node_modules/`.
220
+ Without it, a script under any directory named `node_modules`, at any depth, is not linted, and
221
+ the contributor can commit a directory of that name anywhere. A wrapper named `node_modules`
222
+ or starting with a dot is covered too. `node_modules/.bin` is not a directory named `bin`:
223
+ regular files there need a `.sh` or `.bash` extension to match; its usual symlinks fail the check.
224
+ - `checkout/.git/` in `globalIgnores` keeps the lint out of the clone's repository data, because
225
+ ESLint's default ignores only a `.git` in the directory it runs in, and a branch called
226
+ `fix/deploy.sh` is a file there that matches `files`.
227
+ - `--max-warnings 0` makes warnings, including the "has no effect" warnings above, fail the run.
228
+ The exit codes are the ones in the
229
+ [continuous integration table](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration).
230
+ - Leave `--cache` off. The recipe needs no cache, and if you add one, it must not come from the
231
+ checkout or from anything else the contributor could write.
232
+
233
+ To find what was left, this command was run with every `fs` call, module load and child process
234
+ logged, against a checkout holding each ESLint input named on this page and a marker-writing copy
235
+ of every package in the policy's install. Under the checkout, ESLint only listed directories and read the
236
+ files it lints. It loaded no module from there and started no process. That is a result for the
237
+ ESLint versions above, not a promise from ESLint; this page's tests repeat the trace on the version
238
+ the repository uses.
239
+
240
+ The workflow that runs this command is part of the gate too: keep it, like the policy, where the
241
+ contributor's change can't edit it. Nothing of the contributor's should have run on the machine
242
+ before it, either: an install, build or test run in the same job can change any file the job can
243
+ write, including the policy.
244
+
245
+ ## Declaration conventions that read files
246
+
247
+ A [`DeclarationConvention`](./declaration-conventions.md) is code in your config, and one that reads
248
+ files reads whatever path it is given. When linting code you don't control, read only paths you
249
+ control: a file built from the policy config's own location (`new URL('./commands.txt',
250
+ import.meta.url)`), or data in the config itself. Don't read a file located from the linted script's
251
+ path, such as the per-script sidecars in
252
+ [declarations outside the script](./declaration-conventions.md#declarations-outside-the-script) or
253
+ `scriptSidecarCommands()` in the repository's
254
+ [container-entrypoint example pack](https://github.com/mike-north/eslint-sh/tree/main/examples/container-entrypoint-pack):
255
+ in a checkout under review, every file next to a script belongs to the contributor.
256
+
257
+ ## Which files get linted
258
+
259
+ ESLint lints the files the policy's `files` patterns match, and the contributor chooses file names.
260
+ With the policy above, a checkout with a clean `ok.sh` and these scripts, each starting `#!/bin/bash`
261
+ and containing `eval "$x"`, exits 0 with no report from the recipe:
262
+
263
+ - `ci/run` and `deploy`: no extension, outside any `bin/`.
264
+ - `build.SH`: the patterns are case-sensitive.
265
+ - `x.zsh`: the extension is not in the patterns.
266
+ - `script.sh.txt`: the pattern needs the name to end in `.sh`.
267
+
268
+ Naming a script differently is enough to avoid being linted. Widening the patterns does not close
269
+ this, because any list of names is a list the contributor can step around. What you can do is
270
+ check the checkout for files that start with a shell shebang and that the patterns do not match,
271
+ and fail the job when there are any. Run this from `job-directory`, before the lint, from a workflow
272
+ the contributor can't edit:
273
+
274
+ ```sh
275
+ [ -d checkout ] && [ ! -L checkout ] || { printf 'no checkout directory here\n' >&2; exit 2; }
276
+ status=0
277
+ while IFS= read -r -d '' file; do
278
+ if [ -L "$file" ] || [ ! -f "$file" ] || [ ! -r "$file" ]; then
279
+ printf 'symlink, special or unreadable file: %q\n' "$file" >&2
280
+ status=1
281
+ continue
282
+ fi
283
+ case "$file" in
284
+ *.sh | *.bash) continue ;;
285
+ esac
286
+ # The policy covers direct children of every bin, regardless of wrappers.
287
+ case "${file%/*}" in
288
+ */bin) continue ;;
289
+ esac
290
+ if head -c 1024 "$file" | head -n 1 | LC_ALL=C grep -Eiq '^#!.*sh'; then
291
+ printf 'not linted, but has a shell shebang: %q\n' "$file" >&2
292
+ status=1
293
+ fi
294
+ done < <(find checkout -path checkout/.git -type d -prune -o ! -type d -print0)
295
+ [ "$status" -eq 0 ] || exit "$status"
296
+ ```
297
+
298
+ This needs `bash` (it uses `read -d` and process substitution). It exits non-zero when it finds
299
+ something and otherwise falls through, so it can sit above the recipe in one script or run as its
300
+ own step. Its first line exits 2 without a real `checkout` directory, because a failing `find`
301
+ wouldn't fail the check, and a check run from the wrong directory would pass every time. The first
302
+ `case` covers the extensions; the second checks the parent directory, so it covers direct children
303
+ of every `bin/` without accidentally accepting `bin/sub/run`. Keep these checks in sync with the
304
+ policy's `files`.
305
+
306
+ To rename `checkout`, change it everywhere. Left behind in the guards or the recipe's argument, the
307
+ old name stops the run, and in `globalIgnores` it can only add failures. The policy's `files`, the
308
+ `case` and the `find` must change together: if only `files` is left behind, `.sh` files are still
309
+ linted, so nothing looks wrong, but `bin/` silently isn't.
310
+
311
+ The check looks everywhere in the checkout except a top-level `.git` directory, including under
312
+ `node_modules`. The lint skips that directory too, and only a tarball can put files of its own there,
313
+ so don't run anything from it. The check names every file in the example above except `ok.sh`, and it
314
+ fails on any symlink, special file or file it can't read (see [symlinks](#what-this-does-not-cover)),
315
+ so an installed `node_modules`, whose `.bin` holds symlinks, fails it too. A directory it can't read
316
+ only makes `find` print an error, and the recipe then exits 2 on it. It flags any first line starting `#!` that contains `sh` in any
317
+ case, so `#!/usr/local/bin/publish` fails it as well. When it fails, either fix the name or extend
318
+ the policy's `files` to cover the file.
319
+
320
+ It uses `find` where `git ls-files` would be shorter, because git reads the checkout's own repository
321
+ configuration, which a checkout you did not clone yourself can set, and that configuration can run
322
+ commands. Don't run git in such a checkout, including to list changed files.
323
+
324
+ It is not a complete answer. A script with no shebang, outside every `bin/` and without a `.sh` or
325
+ `.bash` extension, passes both the lint and this check, even if something runs it as `sh deploy` or
326
+ `source`s it. So does one whose
327
+ shebang doesn't contain `sh`, such as a wrapper with another name. The check narrows what a
328
+ contributor can hide; it does not make the set of linted files equal to the set of scripts the
329
+ project runs.
330
+
331
+ The lint sees the files on disk, not the commit, so run it on a file system that keeps every name
332
+ apart, such as Linux's: on macOS's default, a commit with both `A.sh` and `a.sh` clones to one file,
333
+ and only that file is linted. The same goes for names that differ only in Unicode normal form,
334
+ which collide on APFS even on a case-sensitive volume. Lint what you will run, too. A plain clone leaves a submodule's directory empty, so use
335
+ `git clone --recurse-submodules` if anything later fetches submodules. And the contributor's
336
+ `.gitattributes` can mark a file `export-ignore`, which keeps it out of a `git archive` tarball, so
337
+ a tarball can lint clean while a clone of the same commit holds the file.
338
+
339
+ An honest repository can also fail the recipe. ESLint lints `*.js`, `*.mjs` and `*.cjs` by default,
340
+ whatever the policy's `files` say, and `checkout/**/bin/?*` covers everything directly in any `bin/`
341
+ under `checkout`, including vendored binaries. Those files are linted as shell, and can fail with
342
+ parse errors or shell policy findings. A JavaScript file with a disable comment, a JavaScript file
343
+ that ESLint's default parser rejects, or a
344
+ Python script in `bin/` each exits 1. So does any JavaScript file directly in `bin/`, however valid,
345
+ with a message telling you to add a `files` entry that is already there, and an empty `bin/.gitkeep`.
346
+ A checkout with nothing to lint exits 2 with `all of the files matching the glob pattern ... are
347
+ ignored`. These failures are closed rather than open. The policy's `files` and `ignores` are yours
348
+ to adjust, as long as the check's `case` patterns stay the same as what you leave linted.
349
+
350
+ ## What this does not cover
351
+
352
+ Run this way, ESLint reads the checkout's files only as text to lint. That is all the recipe does.
353
+
354
+ **A clean lint is not evidence that a script is harmless.** Exit 0 means your policy's rules found
355
+ nothing in the files your policy matched. The rules look for patterns; they do not work out what a
356
+ script does. Under `sh.configs.recommended`, `eval "$1"` is an error and `bash -c "$1"`, which also
357
+ runs its argument as code, exits 0 with no report. Passing text to a shell another way, or
358
+ downloading a file and running it, passes too. Don't take a clean lint as a reason to run a script
359
+ you haven't reviewed.
360
+
361
+ **It is not a sandbox.** Linting parses the contributor's files in the job's own process, with the
362
+ job's environment variables and file access. The linter does not execute the scripts under review:
363
+ a script that touches a file at its top level, in `$(...)`, in `<(...)`, in an `eval` string, or
364
+ in a `source`d file created none of the test's marker files. But that is not isolation. If a leaked
365
+ secret or a parser fault in the job is the concern, run the lint in a container or a CI job that has
366
+ no secrets and no network access it does not need. Give the job a time limit: a named pipe called
367
+ `f.sh`, which a tarball can deliver though git can't, hung the run until it was killed. This page
368
+ did not test pathological inputs exhaustively.
369
+
370
+ **The report contains the contributor's text.** ESLint prints file names as they are, and rule
371
+ messages quote parts of the script. A file name containing newlines can print lines that look like
372
+ a clean summary. The exit code doesn't depend on any of that text, so gate on the exit code, and
373
+ treat the report as untrusted wherever you display it.
374
+
375
+ **`noInlineConfig` does not cover a script's own declarations.** `noInlineConfig` ignores ESLint's
376
+ comments. It leaves alone anything a `DeclarationConvention` reads out of the script. With
377
+ `commentPragma` bound, `sh/no-undeclared-commands` at `error`, and `noInlineConfig: true`, this
378
+ script has two reports, for `curl` and for `sh`:
379
+
380
+ ```sh
381
+ #!/bin/bash
382
+ set -euo pipefail
383
+ curl https://example.com/install | sh
384
+ ```
385
+
386
+ and this one, whose header declares them itself, has none and the run exits 0:
387
+
388
+ ```sh
389
+ #!/bin/bash
390
+ # uses: curl sh
391
+ set -euo pipefail
392
+ curl https://example.com/install | sh
393
+ ```
394
+
395
+ Don't bind `commentPragma`, or any convention that reads declarations from the script, in a policy
396
+ for code you don't control. To use the declaration rules as a gate, take the declarations from the
397
+ policy: a convention that returns a list written in the config, which the script cannot add to.
398
+
399
+ ```js
400
+ // policy/eslint.config.mjs
401
+ const allowed = ['jq', 'git'];
402
+
403
+ /** Declarations come from this config, never from the script. */
404
+ const policyDeclarations = {
405
+ declaredCommands: () =>
406
+ allowed.map((name) => ({
407
+ kind: 'described',
408
+ name,
409
+ description: 'the allowed commands in policy/eslint.config.mjs',
410
+ })),
411
+ };
412
+
413
+ // In the config entry: settings: { sh: { declarationConvention: policyDeclarations } },
414
+ ```
415
+
416
+ With that convention, the script with the `# uses: curl sh` header is reported for `curl` and `sh`,
417
+ the same as the one without it.
418
+
419
+ **A relative sidecar path resolves against the working directory.** With the recipe, that is
420
+ `job-directory`. With the example pack's `sidecarCommands('commands.txt')` in the policy and no such
421
+ file in `job-directory`, the config fails to load and ESLint exits 2, whatever the checkout holds.
422
+ A path that reaches into the checkout hands over the list: with
423
+ `sidecarCommands('checkout/commands.txt')` and that file listing `curl` and `sh`, the script above
424
+ exits 0. The same config with the absolute path of a file in your policy,
425
+ `sidecarCommands('/ci/policy/commands.txt')`, reports both commands. Give a convention an absolute
426
+ path to a file outside the checkout, or build it from `import.meta.url` as
427
+ [above](#declaration-conventions-that-read-files), or use no sidecar.
428
+
429
+ **A symlink in the checkout can point outside it.** A file symlink whose name matches the policy's
430
+ `files`, or ESLint's default JavaScript patterns, is followed: ESLint lints the file it points at,
431
+ wherever that is. A `leak.sh` linking to a
432
+ text file outside the checkout printed that file's parse error, such as ``Parsing error: `if` must
433
+ be followed by a statement list``, and a link to a script outside the checkout printed rule
434
+ messages naming its unquoted variables and their line numbers. If the report is posted where the
435
+ contributor can read it, that leaks a little of whatever the link points to, and text inside an
436
+ unquoted `$(...)` prints verbatim. Other symlinked directories are not followed: files behind a link
437
+ to a directory were not linted or reported. A link to a directory or to nothing stops the run with
438
+ exit 2 if its own name matches `files` or ESLint's default JavaScript patterns, and is skipped
439
+ otherwise. The check above rejects every symlink under the checkout, including one named
440
+ `node_modules` or `.git`, which closes the file case when it runs before the lint.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eslint-sh/eslint-plugin",
3
3
  "description": "Shell script linting for ESLint, with built-in policy rules, shared presets, and custom rules over a typed shell AST",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "author": "Mike North <michael.l.north@gmail.com>",
6
6
  "dependencies": {
7
7
  "@eslint/core": "^1.2.1",