@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 +6 -1
- package/dist/configs/bash-3-2.js +1 -0
- package/dist/configs/posix-portable.js +1 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
- package/dist/rules/compat/version-gates.d.ts +1 -1
- package/dist/rules/compat/version-gates.js +8 -0
- package/dist/rules/index.js +2 -0
- package/dist/rules/require-tty-guard.js +30 -1
- package/docs/compat.md +15 -8
- package/docs/rules/README.md +2 -1
- package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
- package/docs/rules/require-tty-guard.md +4 -1
- package/docs/shellcheck.md +1 -0
- package/docs/untrusted-code.md +440 -0
- package/package.json +1 -1
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
|
|
package/dist/configs/bash-3-2.js
CHANGED
|
@@ -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".
|
package/dist/rules/index.js
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
package/docs/rules/README.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/shellcheck.md
CHANGED
|
@@ -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.
|
|
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",
|