@eslint-sh/eslint-plugin 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -17
- package/dist/command-harvest/builtins/bash.d.ts +36 -0
- package/dist/command-harvest/builtins/bash.js +98 -0
- package/dist/command-harvest/builtins/bats.d.ts +45 -0
- package/dist/command-harvest/builtins/bats.js +33 -0
- package/dist/command-harvest/builtins/index.d.ts +18 -0
- package/dist/command-harvest/builtins/index.js +39 -0
- package/dist/command-harvest/builtins/posix.d.ts +51 -0
- package/dist/command-harvest/builtins/posix.js +93 -0
- package/dist/command-harvest/harvest-commands.d.ts +66 -0
- package/dist/command-harvest/harvest-commands.js +97 -0
- package/dist/command-harvest/wrapper-table.d.ts +24 -0
- package/dist/command-harvest/wrapper-table.js +49 -0
- package/dist/configs/agent-authored.d.ts +41 -0
- package/dist/configs/agent-authored.js +33 -0
- package/dist/configs/bash-3-2.d.ts +28 -0
- package/dist/configs/bash-3-2.js +50 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +52 -0
- package/dist/configs/recommended.d.ts +52 -0
- package/dist/configs/recommended.js +34 -0
- package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
- package/dist/declaration-conventions/comment-pragma.js +153 -0
- package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
- package/dist/declaration-conventions/declaration-convention.js +1 -0
- package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
- package/dist/declaration-conventions/read-declared-commands.js +128 -0
- package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
- package/dist/eslint-plugin-syntax.d.ts +27 -0
- package/dist/eslint-plugin.d.ts +1211 -52
- package/dist/index.d.ts +56 -8
- package/dist/index.js +53 -6
- package/dist/language/command-resolution.d.ts +124 -0
- package/dist/language/command-resolution.js +120 -0
- package/dist/language/end-of-input-marker.d.ts +29 -0
- package/dist/language/end-of-input-marker.js +88 -0
- package/dist/language/line-breaks.d.ts +18 -0
- package/dist/language/line-breaks.js +22 -0
- package/dist/language/lint-view-types.d.ts +104 -0
- package/dist/language/lint-view-types.js +1 -0
- package/dist/language/non-shell-file-guard.d.ts +35 -0
- package/dist/language/non-shell-file-guard.js +59 -0
- package/dist/language/selector-classes.d.ts +0 -1
- package/dist/language/selector-classes.js +0 -1
- package/dist/language/shell-language-options.d.ts +7 -1
- package/dist/language/shell-language-options.js +4 -4
- package/dist/language/shell-language.d.ts +10 -2
- package/dist/language/shell-language.js +59 -8
- package/dist/language/shell-source-code.d.ts +342 -5
- package/dist/language/shell-source-code.js +559 -30
- package/dist/language/source-coordinates.d.ts +13 -2
- package/dist/language/source-coordinates.js +68 -14
- package/dist/language/syntax-view.d.ts +60 -39
- package/dist/language/syntax-view.js +200 -45
- package/dist/language/word-resolution.d.ts +4 -3
- package/dist/language/word-resolution.js +4 -3
- package/dist/rules/compat/create-compat-rule.d.ts +59 -0
- package/dist/rules/compat/create-compat-rule.js +65 -0
- package/dist/rules/compat/declaration-options.d.ts +19 -0
- package/dist/rules/compat/declaration-options.js +110 -0
- package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
- package/dist/rules/compat/no-append-all-redirect.js +73 -0
- package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
- package/dist/rules/compat/no-associative-arrays.js +39 -0
- package/dist/rules/compat/no-case-attributes.d.ts +12 -0
- package/dist/rules/compat/no-case-attributes.js +63 -0
- package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
- package/dist/rules/compat/no-case-fallthrough.js +67 -0
- package/dist/rules/compat/no-case-modification.d.ts +35 -0
- package/dist/rules/compat/no-case-modification.js +86 -0
- package/dist/rules/compat/no-coproc.d.ts +12 -0
- package/dist/rules/compat/no-coproc.js +47 -0
- package/dist/rules/compat/no-declare-g.d.ts +12 -0
- package/dist/rules/compat/no-declare-g.js +59 -0
- package/dist/rules/compat/no-double-bracket.d.ts +12 -0
- package/dist/rules/compat/no-double-bracket.js +52 -0
- package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
- package/dist/rules/compat/no-epoch-variables.js +57 -0
- package/dist/rules/compat/no-fd-variable.d.ts +12 -0
- package/dist/rules/compat/no-fd-variable.js +48 -0
- package/dist/rules/compat/no-globstar.d.ts +12 -0
- package/dist/rules/compat/no-globstar.js +39 -0
- package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
- package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
- package/dist/rules/compat/no-let.d.ts +12 -0
- package/dist/rules/compat/no-let.js +62 -0
- package/dist/rules/compat/no-mapfile.d.ts +13 -0
- package/dist/rules/compat/no-mapfile.js +122 -0
- package/dist/rules/compat/no-nameref.d.ts +12 -0
- package/dist/rules/compat/no-nameref.js +44 -0
- package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
- package/dist/rules/compat/no-negative-array-index.js +123 -0
- package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
- package/dist/rules/compat/no-nounset-empty-array.js +158 -0
- package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
- package/dist/rules/compat/no-parameter-transformation.js +123 -0
- package/dist/rules/compat/no-pipe-all.d.ts +13 -0
- package/dist/rules/compat/no-pipe-all.js +68 -0
- package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
- package/dist/rules/compat/no-printf-time-format.js +99 -0
- package/dist/rules/compat/no-source.d.ts +13 -0
- package/dist/rules/compat/no-source.js +103 -0
- package/dist/rules/compat/no-test-v.d.ts +12 -0
- package/dist/rules/compat/no-test-v.js +280 -0
- package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
- package/dist/rules/compat/no-unicode-escape.js +120 -0
- package/dist/rules/compat/no-wait-n.d.ts +12 -0
- package/dist/rules/compat/no-wait-n.js +73 -0
- package/dist/rules/compat/operator-range.d.ts +10 -0
- package/dist/rules/compat/operator-range.js +15 -0
- package/dist/rules/compat/posix-absent-command.d.ts +12 -0
- package/dist/rules/compat/posix-absent-command.js +134 -0
- package/dist/rules/compat/shopt-options.d.ts +9 -0
- package/dist/rules/compat/shopt-options.js +63 -0
- package/dist/rules/compat/version-gates.d.ts +76 -0
- package/dist/rules/compat/version-gates.js +197 -0
- package/dist/rules/create-rule.d.ts +91 -0
- package/dist/rules/create-rule.js +51 -0
- package/dist/rules/create-wrapper-rule.d.ts +166 -0
- package/dist/rules/create-wrapper-rule.js +407 -0
- package/dist/rules/define-shell-rule.d.ts +184 -0
- package/dist/rules/define-shell-rule.js +56 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.js +88 -0
- package/dist/rules/max-script-complexity.d.ts +28 -0
- package/dist/rules/max-script-complexity.js +130 -0
- package/dist/rules/no-dynamic-invocation.d.ts +35 -0
- package/dist/rules/no-dynamic-invocation.js +117 -0
- package/dist/rules/no-eval.d.ts +13 -0
- package/dist/rules/no-eval.js +89 -0
- package/dist/rules/no-masked-exit-status.d.ts +21 -0
- package/dist/rules/no-masked-exit-status.js +667 -0
- package/dist/rules/no-secret-env-reads.d.ts +24 -0
- package/dist/rules/no-secret-env-reads.js +133 -0
- package/dist/rules/no-secret-flags.d.ts +32 -0
- package/dist/rules/no-secret-flags.js +162 -0
- package/dist/rules/no-unchecked-cd.d.ts +44 -0
- package/dist/rules/no-unchecked-cd.js +250 -0
- package/dist/rules/no-undeclared-commands.d.ts +20 -0
- package/dist/rules/no-undeclared-commands.js +115 -0
- package/dist/rules/no-unused-declarations.d.ts +23 -0
- package/dist/rules/no-unused-declarations.js +259 -0
- package/dist/rules/prefer-local.d.ts +16 -0
- package/dist/rules/prefer-local.js +249 -0
- package/dist/rules/quote-expansions.d.ts +13 -0
- package/dist/rules/quote-expansions.js +229 -0
- package/dist/rules/require-network-wrapper.d.ts +40 -0
- package/dist/rules/require-network-wrapper.js +244 -0
- package/dist/rules/require-shebang.d.ts +29 -0
- package/dist/rules/require-shebang.js +101 -0
- package/dist/rules/require-strict-mode.d.ts +52 -0
- package/dist/rules/require-strict-mode.js +177 -0
- package/dist/rules/require-tty-guard.d.ts +13 -0
- package/dist/rules/require-tty-guard.js +289 -0
- package/dist/rules/resolve-declaration-convention.d.ts +96 -0
- package/dist/rules/resolve-declaration-convention.js +109 -0
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +116 -0
- package/dist/rules/set-options.d.ts +109 -0
- package/dist/rules/set-options.js +201 -0
- package/dist/rules/shebang-interpreter.d.ts +18 -0
- package/dist/rules/shebang-interpreter.js +81 -0
- package/dist/rules/shebang-matches-dialect.d.ts +12 -0
- package/dist/rules/shebang-matches-dialect.js +69 -0
- package/dist/rules/transparent-wrappers.d.ts +52 -0
- package/dist/rules/transparent-wrappers.js +99 -0
- package/dist/rules/user-pattern.d.ts +63 -0
- package/dist/rules/user-pattern.js +184 -0
- package/dist/syntax/analyze.d.ts +19 -0
- package/dist/syntax/analyze.js +21 -0
- package/dist/syntax/index.d.ts +23 -0
- package/dist/syntax/index.js +25 -0
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/docs/compat.md +347 -0
- package/docs/custom-rule-tutorial.md +780 -34
- package/docs/declaration-conventions.md +478 -0
- package/docs/examples/audit-wrapper-pack.mjs +15 -0
- package/docs/examples/dep-line-convention.mjs +22 -0
- package/docs/examples/eslint.config.mjs +3 -2
- package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
- package/docs/examples/no-backticks-rule-tester.mjs +106 -0
- package/docs/examples/no-backticks-rule.mjs +60 -0
- package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
- package/docs/examples/no-function-keyword-rule.mjs +59 -0
- package/docs/examples/no-listed-commands-rule.ts +63 -0
- package/docs/examples/no-rm-rf-rule.mjs +26 -15
- package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
- package/docs/examples/sidecar-convention.mjs +38 -0
- package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
- package/docs/examples/sorted-declarations-rule.mjs +52 -0
- package/docs/reading-nodes.md +159 -0
- package/docs/rules/README.md +78 -0
- package/docs/rules/_template.md +83 -0
- package/docs/rules/compat/no-append-all-redirect.md +87 -0
- package/docs/rules/compat/no-associative-arrays.md +95 -0
- package/docs/rules/compat/no-case-attributes.md +82 -0
- package/docs/rules/compat/no-case-fallthrough.md +90 -0
- package/docs/rules/compat/no-case-modification.md +74 -0
- package/docs/rules/compat/no-coproc.md +76 -0
- package/docs/rules/compat/no-declare-g.md +80 -0
- package/docs/rules/compat/no-double-bracket.md +72 -0
- package/docs/rules/compat/no-epoch-variables.md +79 -0
- package/docs/rules/compat/no-fd-variable.md +78 -0
- package/docs/rules/compat/no-globstar.md +74 -0
- package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
- package/docs/rules/compat/no-let.md +78 -0
- package/docs/rules/compat/no-mapfile.md +118 -0
- package/docs/rules/compat/no-nameref.md +81 -0
- package/docs/rules/compat/no-negative-array-index.md +87 -0
- package/docs/rules/compat/no-nounset-empty-array.md +103 -0
- package/docs/rules/compat/no-parameter-transformation.md +94 -0
- package/docs/rules/compat/no-pipe-all.md +85 -0
- package/docs/rules/compat/no-printf-time-format.md +75 -0
- package/docs/rules/compat/no-source.md +98 -0
- package/docs/rules/compat/no-test-v.md +82 -0
- package/docs/rules/compat/no-unicode-escape.md +91 -0
- package/docs/rules/compat/no-wait-n.md +85 -0
- package/docs/rules/max-script-complexity.md +109 -0
- package/docs/rules/no-dynamic-invocation.md +139 -0
- package/docs/rules/no-eval.md +106 -0
- package/docs/rules/no-masked-exit-status.md +239 -0
- package/docs/rules/no-secret-env-reads.md +173 -0
- package/docs/rules/no-secret-flags.md +169 -0
- package/docs/rules/no-unchecked-cd.md +146 -0
- package/docs/rules/no-undeclared-commands.md +141 -0
- package/docs/rules/no-unused-declarations.md +216 -0
- package/docs/rules/prefer-local.md +221 -0
- package/docs/rules/quote-expansions.md +157 -0
- package/docs/rules/require-network-wrapper.md +171 -0
- package/docs/rules/require-shebang.md +157 -0
- package/docs/rules/require-strict-mode.md +253 -0
- package/docs/rules/require-tty-guard.md +128 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +86 -0
- package/docs/wrapper-rules.md +206 -0
- package/package.json +26 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/language/selector-classes.d.ts.map +0 -1
- package/dist/language/selector-classes.js.map +0 -1
- package/dist/language/shell-language-options.d.ts.map +0 -1
- package/dist/language/shell-language-options.js.map +0 -1
- package/dist/language/shell-language.d.ts.map +0 -1
- package/dist/language/shell-language.js.map +0 -1
- package/dist/language/shell-source-code.d.ts.map +0 -1
- package/dist/language/shell-source-code.js.map +0 -1
- package/dist/language/source-coordinates.d.ts.map +0 -1
- package/dist/language/source-coordinates.js.map +0 -1
- package/dist/language/syntax-view.d.ts.map +0 -1
- package/dist/language/syntax-view.js.map +0 -1
- package/dist/language/word-resolution.d.ts.map +0 -1
- package/dist/language/word-resolution.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sh/no-masked-exit-status`: a declaration builtin whose value is a command
|
|
3
|
+
* substitution reports the builtin's exit status, not the command's.
|
|
4
|
+
*
|
|
5
|
+
* `local x=$(cmd)`, `export X=$(cmd)`, `readonly X=$(cmd)`, `declare x=$(cmd)`
|
|
6
|
+
* and `typeset x=$(cmd)` all succeed whatever `cmd` did, so `set -e` does not
|
|
7
|
+
* stop the script and `… && next` runs `next` anyway. A script that turns
|
|
8
|
+
* strict mode on still swallows the failure there (shellcheck SC2155).
|
|
9
|
+
*
|
|
10
|
+
* **What is reported.** A `local`, `declare`, `typeset`, `export` or
|
|
11
|
+
* `readonly` operand of the form `name=value` whose value contains a command
|
|
12
|
+
* substitution (`$(…)` or backticks) at any depth of the word: inside double
|
|
13
|
+
* quotes, next to other text, inside a parameter expansion's operand, a
|
|
14
|
+
* substring offset or an array subscript, and inside an arithmetic expansion.
|
|
15
|
+
* Real shells give a plain assignment the status of a substitution in any of
|
|
16
|
+
* those places, so the declaration hides it from all of them. A process
|
|
17
|
+
* substitution (`<(…)`) is not looked into: it expands to a path. The builtin
|
|
18
|
+
* is also read behind the transparent wrappers `builtin` and `command`. One
|
|
19
|
+
* report is made per operand, on the operand. An array value
|
|
20
|
+
* (`local -a a=($(cmd))`) is not reported: shellcheck 0.11.0 does not report
|
|
21
|
+
* it either, and a status-bearing scalar is the construct the rule exists for.
|
|
22
|
+
*
|
|
23
|
+
* Not reported: a value with no substitution, a declaration with no value,
|
|
24
|
+
* a plain assignment (`x=$(cmd)` keeps the command's status), and the split
|
|
25
|
+
* form `local x; x=$(cmd)`.
|
|
26
|
+
*
|
|
27
|
+
* **Options.** `builtins` limits which of the five builtins are checked.
|
|
28
|
+
* `onlyWithErrexit` reports only where `errexit` is known to be on, except
|
|
29
|
+
* where the script reads the status explicitly: the left side of `&&` / `||`,
|
|
30
|
+
* the right side of one that is itself read (the middle of `a && b && c`), and
|
|
31
|
+
* the last command of an `if`/`elif`/`while`/`until` condition. There the
|
|
32
|
+
* status is the builtin's whatever the options. `errexit` is known to be on
|
|
33
|
+
* for a statement when the plain top-level `set` statements before it (read
|
|
34
|
+
* with `require-strict-mode`'s `set` reading) leave it on, wherever they sit,
|
|
35
|
+
* and, with `allowShebangOnly`, when the shebang line enables it. A function
|
|
36
|
+
* body is read at the point it is defined. Under the `bats` dialect a
|
|
37
|
+
* statement written directly in an `@test` body or a top-level `setup` /
|
|
38
|
+
* `teardown` hook is on, as in `no-unchecked-cd`. A `set` inside a function,
|
|
39
|
+
* a group or a condition, in a sourced file, or `set -e` on a command line
|
|
40
|
+
* (`bash -e script.sh`) is not seen.
|
|
41
|
+
*
|
|
42
|
+
* **No autofix, only a suggestion.** Splitting the declaration changes
|
|
43
|
+
* behavior on purpose: under `set -e` a failure now stops the script. The
|
|
44
|
+
* suggestion declares the names first and assigns each substituted one on its
|
|
45
|
+
* own statement, keeping the builtin's flags; `export` assigns first and
|
|
46
|
+
* exports afterwards, because exporting first would put the old value in the
|
|
47
|
+
* substitution's environment. It is withheld where splitting
|
|
48
|
+
* would not be equivalent, would change the surrounding list, or would turn
|
|
49
|
+
* invalid input into a valid assignment:
|
|
50
|
+
*
|
|
51
|
+
* - the value reads a name the split moves before it. For `local`, `declare`
|
|
52
|
+
* and `typeset` inside a function that includes the variable itself (the
|
|
53
|
+
* declaration creates an empty local, so the old value the substitution
|
|
54
|
+
* read is gone); for every builtin it includes the other operands of the
|
|
55
|
+
* same declaration, which the split assigns earlier or later than before.
|
|
56
|
+
* Any mention of such a name in the value counts, because a command can
|
|
57
|
+
* read a variable by name (`[ -v x ]`, `declare -p x`, `printenv x`), and
|
|
58
|
+
* under `-i` the builtin evaluates values as arithmetic, and `export` runs
|
|
59
|
+
* after the assignments, so both reach the values without a substitution;
|
|
60
|
+
* - a scoped declaration of a variable the shell or its commands consult
|
|
61
|
+
* (`local PATH=$(cmd)`): the declaration empties it before the command runs;
|
|
62
|
+
* - code the rule cannot read may read such a name: the value runs `eval`,
|
|
63
|
+
* `source` or `.`, a command whose name is not static, or a function
|
|
64
|
+
* defined in this file. This applies where the split changes what is
|
|
65
|
+
* visible: `local`, `declare` or `typeset` inside a function, several
|
|
66
|
+
* operands, or any flag. A function defined in another file cannot be seen,
|
|
67
|
+
* so a call to it is not detected;
|
|
68
|
+
* - the statement has prefix assignments (`A=1 export x=$(cmd)`), which the
|
|
69
|
+
* replacement would delete;
|
|
70
|
+
* - a flag changes the meaning of the split or is not valid with an
|
|
71
|
+
* assignment: `-g` (the split writes the local, not the global), `-x` on
|
|
72
|
+
* `local`, `declare` or `typeset` (declaring first exports the name to the
|
|
73
|
+
* substitution), `-n` on those builtins (the assignment writes through a
|
|
74
|
+
* nameref that already exists), `-f`, `-F`, `-p`, `export -x`, or any flag
|
|
75
|
+
* not static;
|
|
76
|
+
* - `local` outside a function, which is an error;
|
|
77
|
+
* - the declaration is not a standalone statement: an operand of `&&` / `||`
|
|
78
|
+
* or a pipeline, a condition, negated, backgrounded, redirected, or the
|
|
79
|
+
* body of a function written without braces;
|
|
80
|
+
* - the declaration is reached through `builtin` or `command`;
|
|
81
|
+
* - an operand is an append (`+=`) or has a subscript (`a[1]=…`).
|
|
82
|
+
*
|
|
83
|
+
* `-r` (and `readonly` itself) forbids a later assignment, so those forms are
|
|
84
|
+
* split into the declaration, the assignment, then `readonly <names>`.
|
|
85
|
+
*/
|
|
86
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
87
|
+
import { childNodes } from '../language/syntax-view.js';
|
|
88
|
+
import { lineBreakOf } from '../language/line-breaks.js';
|
|
89
|
+
import { createRule } from './create-rule.js';
|
|
90
|
+
import { batsErrexitIsOn, isStatusChecked } from './no-unchecked-cd.js';
|
|
91
|
+
import { setStatementChanges } from './require-strict-mode.js';
|
|
92
|
+
import { applySetOptionChanges, shebangOptionChanges } from './set-options.js';
|
|
93
|
+
import { BUILTIN_WRAPPER } from './transparent-wrappers.js';
|
|
94
|
+
/** Every declaration builtin the rule can check; the default for `builtins`. */
|
|
95
|
+
const ALL_BUILTINS = ['local', 'declare', 'typeset', 'export', 'readonly'];
|
|
96
|
+
/** The builtins that create a variable in the enclosing function's scope. */
|
|
97
|
+
const SCOPED_BUILTINS = new Set(['local', 'declare', 'typeset']);
|
|
98
|
+
/**
|
|
99
|
+
* Variables the shell or the commands it runs consult. A scoped declaration
|
|
100
|
+
* empties them for the substitution that the split moves after it, so
|
|
101
|
+
* `local PATH; PATH=$(cmd)` cannot even find `cmd`. An exported variable of any
|
|
102
|
+
* other name can be read by a command too, but that is not visible here.
|
|
103
|
+
*/
|
|
104
|
+
const CONSULTED_VARIABLES = new Set([
|
|
105
|
+
'BASH_ENV',
|
|
106
|
+
'CDPATH',
|
|
107
|
+
'ENV',
|
|
108
|
+
'GLOBIGNORE',
|
|
109
|
+
'HOME',
|
|
110
|
+
'IFS',
|
|
111
|
+
'LANG',
|
|
112
|
+
'LANGUAGE',
|
|
113
|
+
'LC_ALL',
|
|
114
|
+
'LC_COLLATE',
|
|
115
|
+
'LC_CTYPE',
|
|
116
|
+
'LC_MESSAGES',
|
|
117
|
+
'LC_NUMERIC',
|
|
118
|
+
'LC_TIME',
|
|
119
|
+
'LD_LIBRARY_PATH',
|
|
120
|
+
'OLDPWD',
|
|
121
|
+
'PATH',
|
|
122
|
+
'POSIXLY_CORRECT',
|
|
123
|
+
'PWD',
|
|
124
|
+
'SHELL',
|
|
125
|
+
'TERM',
|
|
126
|
+
'TMPDIR',
|
|
127
|
+
'TZ',
|
|
128
|
+
'USER',
|
|
129
|
+
]);
|
|
130
|
+
/** A valid shell variable name. */
|
|
131
|
+
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
132
|
+
/**
|
|
133
|
+
* Wrappers followed to find the declaration builtin: `command` (with its
|
|
134
|
+
* `-p`) and `builtin`. Other wrappers (`sudo`, `env`) run external programs,
|
|
135
|
+
* which cannot be declaration builtins.
|
|
136
|
+
*/
|
|
137
|
+
const TRANSPARENT_WRAPPERS = [
|
|
138
|
+
...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => spec.names.includes('command')),
|
|
139
|
+
BUILTIN_WRAPPER,
|
|
140
|
+
];
|
|
141
|
+
/**
|
|
142
|
+
* Whether `node` contains a command substitution whose status the declaration
|
|
143
|
+
* would replace. A substitution counts wherever it sits in the word, including
|
|
144
|
+
* inside an arithmetic expansion, a substring offset or an array subscript:
|
|
145
|
+
* in a plain assignment a failing substitution there fails the assignment
|
|
146
|
+
* (and so trips `set -e`) in Bash, dash, zsh and ksh93. A process
|
|
147
|
+
* substitution is not entered: it expands to a path, so there is no status.
|
|
148
|
+
*/
|
|
149
|
+
function containsCommandSubstitution(node) {
|
|
150
|
+
if (node.type === 'CmdSubst')
|
|
151
|
+
return true;
|
|
152
|
+
if (node.type === 'ProcSubst')
|
|
153
|
+
return false;
|
|
154
|
+
return childNodes(node).some(containsCommandSubstitution);
|
|
155
|
+
}
|
|
156
|
+
/** Whether `text` mentions `name` as a whole identifier. */
|
|
157
|
+
function mentionsIdentifier(text, name) {
|
|
158
|
+
return new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`, 'u').test(text);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Whether evaluating `node` reads any of `names`: a parameter expansion of
|
|
162
|
+
* the name (`$x`, `${x:-…}`, `${a[x]}`), or an arithmetic expansion that
|
|
163
|
+
* mentions it as a bare name (`$((x + 1))`), or an indirect expansion
|
|
164
|
+
* (`${!ref}`), which can read any name. Command substitutions are entered, so
|
|
165
|
+
* `$(echo "$x")` counts. Reads made by code the substitution runs are
|
|
166
|
+
* {@link runsUnseenCode}'s concern.
|
|
167
|
+
*/
|
|
168
|
+
function readsAnyName(node, names, sourceCode) {
|
|
169
|
+
if (names.length === 0)
|
|
170
|
+
return false;
|
|
171
|
+
if (node.type === 'ParamExp') {
|
|
172
|
+
if (node.excl)
|
|
173
|
+
return true;
|
|
174
|
+
const param = node.param?.value;
|
|
175
|
+
if (param !== undefined && names.includes(param))
|
|
176
|
+
return true;
|
|
177
|
+
if (node.index !== undefined) {
|
|
178
|
+
const indexText = sourceCode.text.slice(node.index.range[0], node.index.range[1]);
|
|
179
|
+
if (names.some((name) => mentionsIdentifier(indexText, name)))
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (node.type === 'ArithmExp' || node.type === 'ArithmCmd') {
|
|
184
|
+
const arithmetic = sourceCode.text.slice(node.range[0], node.range[1]);
|
|
185
|
+
if (names.some((name) => mentionsIdentifier(arithmetic, name)))
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
return childNodes(node).some((child) => readsAnyName(child, names, sourceCode));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Whether `node` sits inside a function body: a function definition or a bats
|
|
192
|
+
* `@test` block, which bats runs as a function.
|
|
193
|
+
*/
|
|
194
|
+
function insideFunction(node, sourceCode) {
|
|
195
|
+
for (let up = sourceCode.getParent(node); up !== undefined; up = sourceCode.getParent(up)) {
|
|
196
|
+
if (up.type === 'FuncDecl' || up.type === 'TestDecl')
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Builtins that run code the rule cannot read: they evaluate a string or a
|
|
203
|
+
* file, so they may read any variable.
|
|
204
|
+
*/
|
|
205
|
+
const CODE_RUNNING_BUILTINS = new Set(['eval', 'source', '.']);
|
|
206
|
+
/** The names of the functions defined anywhere in the file, at any depth. */
|
|
207
|
+
function collectFunctionNames(node, into) {
|
|
208
|
+
if (node.type === 'FuncDecl' && node.name !== undefined)
|
|
209
|
+
into.add(node.name.value);
|
|
210
|
+
for (const child of childNodes(node))
|
|
211
|
+
collectFunctionNames(child, into);
|
|
212
|
+
return into;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether evaluating `node` may run code that reads shell variables without
|
|
216
|
+
* showing it: `eval`, `source` / `.`, a command whose name is not static, or
|
|
217
|
+
* a call to a function defined in this file. The split moves a declaration
|
|
218
|
+
* earlier, so such code can see a different variable afterwards. A function
|
|
219
|
+
* defined in another file cannot be seen and is not detected.
|
|
220
|
+
*/
|
|
221
|
+
function runsUnseenCode(node, functionNames, sourceCode) {
|
|
222
|
+
if (node.type === 'CallExpr' && node.args.length > 0) {
|
|
223
|
+
const invoked = sourceCode.resolveInvokedCommand(node, {
|
|
224
|
+
transparentWrappers: TRANSPARENT_WRAPPERS,
|
|
225
|
+
});
|
|
226
|
+
if (invoked?.effective.static !== true)
|
|
227
|
+
return true;
|
|
228
|
+
const name = invoked.effective.text;
|
|
229
|
+
if (CODE_RUNNING_BUILTINS.has(name) || functionNames.has(name))
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return childNodes(node).some((child) => runsUnseenCode(child, functionNames, sourceCode));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The state of `errexit` before each top-level statement of `file`, from the
|
|
236
|
+
* shebang (when `allowShebangOnly`) and the plain top-level `set` statements
|
|
237
|
+
* before it, read in order with `require-strict-mode`'s own `set` reading.
|
|
238
|
+
* Unlike that rule there is no statement window: a `set -e` at any position
|
|
239
|
+
* counts for the statements after it. A `set` inside a function, a group, a
|
|
240
|
+
* condition or a sourced file is not seen.
|
|
241
|
+
*/
|
|
242
|
+
function errexitBeforeEachTopLevelStatement(file, sourceCode, allowShebangOnly) {
|
|
243
|
+
const enabled = applySetOptionChanges(new Set(), allowShebangOnly ? shebangOptionChanges(sourceCode.text) : []);
|
|
244
|
+
const before = new Map();
|
|
245
|
+
for (const stmt of file.stmts) {
|
|
246
|
+
before.set(stmt, enabled.has('errexit'));
|
|
247
|
+
applySetOptionChanges(enabled, setStatementChanges(stmt, sourceCode));
|
|
248
|
+
}
|
|
249
|
+
return before;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether `stmt` is a whole statement of a statement list, so replacing its
|
|
253
|
+
* command with several statements changes nothing about the list around it.
|
|
254
|
+
*/
|
|
255
|
+
function isStandalone(stmt, sourceCode) {
|
|
256
|
+
if (stmt.background || stmt.negated || stmt.redirs.length > 0)
|
|
257
|
+
return false;
|
|
258
|
+
const parent = sourceCode.getParent(stmt);
|
|
259
|
+
switch (parent?.type) {
|
|
260
|
+
case 'File':
|
|
261
|
+
case 'Block':
|
|
262
|
+
case 'Subshell':
|
|
263
|
+
case 'CaseItem':
|
|
264
|
+
return true;
|
|
265
|
+
case 'IfClause':
|
|
266
|
+
case 'WhileClause':
|
|
267
|
+
return !parent.cond.includes(stmt);
|
|
268
|
+
case 'ForClause':
|
|
269
|
+
return parent.do.includes(stmt);
|
|
270
|
+
default:
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** The operand text a declaration keeps for `operand`: its bare name when substituted. */
|
|
275
|
+
function keptText(operand) {
|
|
276
|
+
return operand.substituted ? operand.name : operand.text;
|
|
277
|
+
}
|
|
278
|
+
/** Reads `name=value` operands of an `Assign` list (a parsed `DeclClause`). */
|
|
279
|
+
function operandsOfDeclClause(node, sourceCode) {
|
|
280
|
+
const flags = [];
|
|
281
|
+
const operands = [];
|
|
282
|
+
let flagsKnown = true;
|
|
283
|
+
for (const arg of node.args) {
|
|
284
|
+
if (arg.name === undefined) {
|
|
285
|
+
const flag = arg.value === undefined ? null : sourceCode.resolveWordText(arg.value);
|
|
286
|
+
if (flag === null)
|
|
287
|
+
flagsKnown = false;
|
|
288
|
+
else
|
|
289
|
+
flags.push(flag);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const substituted = !arg.naked &&
|
|
293
|
+
arg.array === undefined &&
|
|
294
|
+
arg.value !== undefined &&
|
|
295
|
+
containsCommandSubstitution(arg.value);
|
|
296
|
+
operands.push({
|
|
297
|
+
node: arg,
|
|
298
|
+
name: arg.name.value,
|
|
299
|
+
substituted,
|
|
300
|
+
values: arg.value === undefined ? [] : [arg.value],
|
|
301
|
+
text: sourceCode.text.slice(arg.range[0], arg.range[1]),
|
|
302
|
+
unsplittable: arg.append || arg.index !== undefined,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return { variant: node.variant?.value ?? '', flags, operands, flagsKnown };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reads `word` as `name=value`, `name+=value` or `name[subscript]=value`, or
|
|
309
|
+
* `null` when it is not an assignment. The text before the `=` can span parts:
|
|
310
|
+
* the parser may split `w[1]=` into two literals, and a subscript may hold
|
|
311
|
+
* expansions and quotes, which are parts of their own, so only literal parts
|
|
312
|
+
* are scanned for the bracket that closes it. A substitution in the subscript
|
|
313
|
+
* is not part of the value, as in a parsed `Assign`.
|
|
314
|
+
*/
|
|
315
|
+
function assignmentOfWord(word) {
|
|
316
|
+
const first = word.parts.at(0);
|
|
317
|
+
if (first?.type !== 'Lit')
|
|
318
|
+
return null;
|
|
319
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/u.exec(first.value)?.[0];
|
|
320
|
+
if (name === undefined)
|
|
321
|
+
return null;
|
|
322
|
+
let depth = 0;
|
|
323
|
+
let subscripted = false;
|
|
324
|
+
for (const [index, part] of word.parts.entries()) {
|
|
325
|
+
if (part.type !== 'Lit') {
|
|
326
|
+
if (depth === 0)
|
|
327
|
+
return null;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
for (let at = index === 0 ? name.length : 0; at < part.value.length; at += 1) {
|
|
331
|
+
const character = part.value[at];
|
|
332
|
+
if (depth > 0) {
|
|
333
|
+
if (character === '\\')
|
|
334
|
+
at += 1;
|
|
335
|
+
else if (character === '[')
|
|
336
|
+
depth += 1;
|
|
337
|
+
else if (character === ']')
|
|
338
|
+
depth -= 1;
|
|
339
|
+
}
|
|
340
|
+
else if (character === '[') {
|
|
341
|
+
subscripted = true;
|
|
342
|
+
depth = 1;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const append = part.value.startsWith('+=', at);
|
|
346
|
+
if (character !== '=' && !append)
|
|
347
|
+
return null;
|
|
348
|
+
return {
|
|
349
|
+
name,
|
|
350
|
+
values: word.parts.slice(index + 1),
|
|
351
|
+
unsplittable: subscripted || append,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Reads a declaration builtin whose operands the parser left as ordinary
|
|
360
|
+
* words: under the `posix` dialect (which has no declaration clause) and
|
|
361
|
+
* behind `builtin` / `command`. A word {@link assignmentOfWord} recognizes
|
|
362
|
+
* is an assignment operand; a static word starting with `-` is
|
|
363
|
+
* a flag; a static bare name is a valueless operand. Any other word (a
|
|
364
|
+
* dynamic one, say) makes the flags unknown, which only withholds the
|
|
365
|
+
* suggestion.
|
|
366
|
+
*/
|
|
367
|
+
function declarationOfWords(variant, words, sourceCode) {
|
|
368
|
+
const flags = [];
|
|
369
|
+
const operands = [];
|
|
370
|
+
let flagsKnown = true;
|
|
371
|
+
for (const word of words) {
|
|
372
|
+
const text = sourceCode.text.slice(word.range[0], word.range[1]);
|
|
373
|
+
const assignment = assignmentOfWord(word);
|
|
374
|
+
if (assignment !== null) {
|
|
375
|
+
operands.push({
|
|
376
|
+
node: word,
|
|
377
|
+
name: assignment.name,
|
|
378
|
+
substituted: assignment.values.some(containsCommandSubstitution),
|
|
379
|
+
values: assignment.values,
|
|
380
|
+
text,
|
|
381
|
+
unsplittable: assignment.unsplittable,
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const resolved = sourceCode.resolveWordText(word);
|
|
386
|
+
if (resolved !== null && VARIABLE_NAME.test(resolved)) {
|
|
387
|
+
operands.push({
|
|
388
|
+
node: word,
|
|
389
|
+
name: resolved,
|
|
390
|
+
substituted: false,
|
|
391
|
+
values: [],
|
|
392
|
+
text,
|
|
393
|
+
unsplittable: false,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
else if (resolved?.startsWith('-') === true) {
|
|
397
|
+
flags.push(resolved);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
flagsKnown = false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { variant, flags, operands, flagsKnown };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The option letters each builtin accepts together with a `name=value`
|
|
407
|
+
* operand, restricted to ones that mean the same in the split form. Anything
|
|
408
|
+
* else (`-g`, which retargets the assignment to the global scope; `-x`, which
|
|
409
|
+
* would export the name to the substitution when declared first; `-n`, whose
|
|
410
|
+
* later assignment writes through a nameref that already exists instead of
|
|
411
|
+
* rebinding it; `-f`, `-F` and `-p`, which do not take assignments; an option
|
|
412
|
+
* the builtin does not have, such as `export -x`) withholds the suggestion, so it never turns an
|
|
413
|
+
* error into an assignment or moves a write to another scope.
|
|
414
|
+
*/
|
|
415
|
+
const VALID_FLAG_LETTERS = {
|
|
416
|
+
local: 'aAilrtu',
|
|
417
|
+
declare: 'aAilrtu',
|
|
418
|
+
typeset: 'aAilrtu',
|
|
419
|
+
export: 'n',
|
|
420
|
+
readonly: '',
|
|
421
|
+
};
|
|
422
|
+
/** Whether every flag word is a short-option cluster of letters valid for `variant`. */
|
|
423
|
+
function flagsAreValid(variant, flags) {
|
|
424
|
+
const letters = VALID_FLAG_LETTERS[variant] ?? '';
|
|
425
|
+
return flags.every((flag) => new RegExp(`^-[${letters}]+$`, 'u').test(flag));
|
|
426
|
+
}
|
|
427
|
+
/** Whether a flag word is a short-option cluster that includes `-i` (integer). */
|
|
428
|
+
function isIntegerFlag(flag) {
|
|
429
|
+
return /^-[A-Za-z]*i[A-Za-z]*$/u.test(flag);
|
|
430
|
+
}
|
|
431
|
+
/** The text after the first `=` of an operand, or `''` for a bare name. */
|
|
432
|
+
function valueText(operand) {
|
|
433
|
+
const equals = operand.text.indexOf('=');
|
|
434
|
+
return equals === -1 ? '' : operand.text.slice(equals + 1);
|
|
435
|
+
}
|
|
436
|
+
/** Whether a flag word is a short-option cluster that includes `-r`. */
|
|
437
|
+
function isReadonlyFlag(flag) {
|
|
438
|
+
return /^-[A-Za-z]*r[A-Za-z]*$/u.test(flag);
|
|
439
|
+
}
|
|
440
|
+
/** `flag` without its `r` letter, or `null` when only the dash would be left. */
|
|
441
|
+
function withoutReadonly(flag) {
|
|
442
|
+
const remaining = flag.replace('r', '');
|
|
443
|
+
return remaining === '-' ? null : remaining;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The replacement text for a standalone declaration, or `null` where no
|
|
447
|
+
* equivalent split exists. `separator` joins the statements.
|
|
448
|
+
*/
|
|
449
|
+
function splitText(declaration, scoped, separator, functionNames, sourceCode) {
|
|
450
|
+
const { variant, flags, operands, flagsKnown } = declaration;
|
|
451
|
+
if (!flagsKnown || operands.some((operand) => operand.unsplittable))
|
|
452
|
+
return null;
|
|
453
|
+
const isReadonlyBuiltin = variant === 'readonly';
|
|
454
|
+
if (isReadonlyBuiltin && flags.length > 0)
|
|
455
|
+
return null;
|
|
456
|
+
if (!flagsAreValid(variant, flags))
|
|
457
|
+
return null;
|
|
458
|
+
if (scoped && operands.some((operand) => CONSULTED_VARIABLES.has(operand.name)))
|
|
459
|
+
return null;
|
|
460
|
+
const names = operands.map((operand) => operand.name);
|
|
461
|
+
// The names a value could read that the split changes: its own when the
|
|
462
|
+
// declaration creates a new scope, and the other operands'.
|
|
463
|
+
const namesAffectingValueOf = (operand) => scoped ? names : names.filter((name) => name !== operand.name);
|
|
464
|
+
for (const operand of operands) {
|
|
465
|
+
if (!operand.substituted)
|
|
466
|
+
continue;
|
|
467
|
+
const others = namesAffectingValueOf(operand);
|
|
468
|
+
if (operand.values.some((value) => readsAnyName(value, others, sourceCode)))
|
|
469
|
+
return null;
|
|
470
|
+
// A command can read a variable by name, with no expansion to see:
|
|
471
|
+
// `[ -v x ]`, `declare -p x`, `printenv x`.
|
|
472
|
+
if (others.some((name) => mentionsIdentifier(valueText(operand), name)))
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
// Operands without a substitution count in two cases. Under `-i` the builtin
|
|
476
|
+
// evaluates each value as arithmetic, where a bare identifier reads a
|
|
477
|
+
// variable. And `export` runs after the assignments, so its other values
|
|
478
|
+
// would read the new value (`export A=$(c) B=$A`).
|
|
479
|
+
if (variant === 'export' || flags.some(isIntegerFlag)) {
|
|
480
|
+
for (const operand of operands) {
|
|
481
|
+
const text = valueText(operand);
|
|
482
|
+
if (namesAffectingValueOf(operand).some((name) => mentionsIdentifier(text, name))) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Code the split cannot see into may read a name that the split declares
|
|
488
|
+
// earlier or in a new scope. A lone, flagless `export` or `readonly`
|
|
489
|
+
// operand declares nothing that code could observe differently.
|
|
490
|
+
const mayObserveDeclaration = scoped || operands.length > 1 || flags.length > 0;
|
|
491
|
+
if (mayObserveDeclaration &&
|
|
492
|
+
operands.some((operand) => operand.values.some((value) => runsUnseenCode(value, functionNames, sourceCode)))) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const makesReadonly = isReadonlyBuiltin || flags.some(isReadonlyFlag);
|
|
496
|
+
const keptFlags = flags.flatMap((flag) => {
|
|
497
|
+
if (!isReadonlyFlag(flag))
|
|
498
|
+
return [flag];
|
|
499
|
+
const stripped = withoutReadonly(flag);
|
|
500
|
+
return stripped === null ? [] : [stripped];
|
|
501
|
+
});
|
|
502
|
+
const substituted = operands.filter((operand) => operand.substituted);
|
|
503
|
+
// `export` creates no scope, so nothing needs it to come first, and coming
|
|
504
|
+
// first would export the old value to the substitution.
|
|
505
|
+
if (variant === 'export') {
|
|
506
|
+
return {
|
|
507
|
+
text: [
|
|
508
|
+
...substituted.map((operand) => operand.text),
|
|
509
|
+
[variant, ...flags, ...operands.map(keptText)].join(' '),
|
|
510
|
+
].join(separator),
|
|
511
|
+
messageId: 'assignThenExport',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const statements = [];
|
|
515
|
+
const declared = isReadonlyBuiltin
|
|
516
|
+
? operands.filter((operand) => !operand.substituted)
|
|
517
|
+
: operands;
|
|
518
|
+
if (declared.length > 0) {
|
|
519
|
+
statements.push([variant, ...keptFlags, ...declared.map(keptText)].join(' '));
|
|
520
|
+
}
|
|
521
|
+
for (const operand of substituted)
|
|
522
|
+
statements.push(operand.text);
|
|
523
|
+
if (makesReadonly) {
|
|
524
|
+
const readonlyNames = isReadonlyBuiltin ? substituted : operands;
|
|
525
|
+
statements.push(`readonly ${readonlyNames.map((operand) => operand.name).join(' ')}`);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
text: statements.join(separator),
|
|
529
|
+
messageId: makesReadonly ? 'assignThenReadonly' : 'declareThenAssign',
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/** Built-in rule `sh/no-masked-exit-status`. @internal */
|
|
533
|
+
export const noMaskedExitStatus = createRule({
|
|
534
|
+
name: 'no-masked-exit-status',
|
|
535
|
+
meta: {
|
|
536
|
+
type: 'problem',
|
|
537
|
+
docs: {
|
|
538
|
+
description: 'disallow a declaration builtin (`local`, `export`, …) assigned from a command substitution, which hides the command’s exit status',
|
|
539
|
+
},
|
|
540
|
+
hasSuggestions: true,
|
|
541
|
+
schema: [
|
|
542
|
+
{
|
|
543
|
+
type: 'object',
|
|
544
|
+
properties: {
|
|
545
|
+
onlyWithErrexit: { type: 'boolean' },
|
|
546
|
+
allowShebangOnly: { type: 'boolean' },
|
|
547
|
+
builtins: {
|
|
548
|
+
type: 'array',
|
|
549
|
+
items: { enum: [...ALL_BUILTINS] },
|
|
550
|
+
minItems: 1,
|
|
551
|
+
uniqueItems: true,
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
additionalProperties: false,
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
defaultOptions: [
|
|
558
|
+
{ onlyWithErrexit: false, allowShebangOnly: false, builtins: [...ALL_BUILTINS] },
|
|
559
|
+
],
|
|
560
|
+
messages: {
|
|
561
|
+
maskedExitStatus: '`{{builtin}}` returns its own exit status instead of the command substitution’s in the value of `{{name}}`, so neither `set -e` nor `&&` sees the command fail. Assign `{{name}}` in a separate statement from `{{builtin}}`.',
|
|
562
|
+
declareThenAssign: 'Declare `{{names}}` first and assign it in a separate statement.',
|
|
563
|
+
assignThenExport: 'Assign `{{names}}` in a separate statement, then export it.',
|
|
564
|
+
assignThenReadonly: 'Declare and assign `{{names}}` in separate statements, then make it read-only.',
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
create(context) {
|
|
568
|
+
const [options] = context.options;
|
|
569
|
+
const { sourceCode } = context;
|
|
570
|
+
const checked = new Set(options.builtins ?? ALL_BUILTINS);
|
|
571
|
+
const parentOf = (node) => sourceCode.getParent(node);
|
|
572
|
+
let errexitBefore = new Map();
|
|
573
|
+
let functionNames = new Set();
|
|
574
|
+
/**
|
|
575
|
+
* Whether `errexit` is known to be on when `stmt` runs: the statement is
|
|
576
|
+
* written directly in a bats `@test` body or hook, or the plain top-level
|
|
577
|
+
* `set` statements before the top-level statement that contains it leave
|
|
578
|
+
* it on. A function body is read at its definition.
|
|
579
|
+
*/
|
|
580
|
+
const errexitIsOn = (stmt) => {
|
|
581
|
+
if (sourceCode.dialect === 'bats' && batsErrexitIsOn(stmt, parentOf, sourceCode))
|
|
582
|
+
return true;
|
|
583
|
+
let topLevel = stmt;
|
|
584
|
+
for (let up = parentOf(topLevel); up !== undefined && up.type !== 'File'; up = parentOf(up)) {
|
|
585
|
+
topLevel = up;
|
|
586
|
+
}
|
|
587
|
+
return errexitBefore.get(topLevel) === true;
|
|
588
|
+
};
|
|
589
|
+
/** Reports each substituted operand of `declaration`, with a suggestion where one is safe. */
|
|
590
|
+
const check = (node, builtin, declaration, offerSuggestion) => {
|
|
591
|
+
if (!checked.has(builtin))
|
|
592
|
+
return;
|
|
593
|
+
const substituted = declaration.operands.filter((operand) => operand.substituted);
|
|
594
|
+
if (substituted.length === 0)
|
|
595
|
+
return;
|
|
596
|
+
const stmt = sourceCode.getParent(node);
|
|
597
|
+
if (stmt?.type !== 'Stmt')
|
|
598
|
+
return;
|
|
599
|
+
if (options.onlyWithErrexit === true &&
|
|
600
|
+
!isStatusChecked(stmt, parentOf) &&
|
|
601
|
+
!errexitIsOn(stmt)) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
let suggestion = null;
|
|
605
|
+
const inFunction = insideFunction(node, sourceCode);
|
|
606
|
+
// `local` outside a function is an error, which a split would turn into
|
|
607
|
+
// an assignment of a global.
|
|
608
|
+
const validHere = builtin !== 'local' || inFunction;
|
|
609
|
+
if (offerSuggestion && validHere && isStandalone(stmt, sourceCode)) {
|
|
610
|
+
const text = sourceCode.text;
|
|
611
|
+
const start = node.range[0];
|
|
612
|
+
const lineStart = Math.max(text.lastIndexOf('\n', start - 1), text.lastIndexOf('\r', start - 1));
|
|
613
|
+
const indent = text.slice(lineStart + 1, start);
|
|
614
|
+
const separator = /^[ \t]*$/u.test(indent) ? lineBreakOf(text) + indent : '; ';
|
|
615
|
+
const scoped = SCOPED_BUILTINS.has(builtin) && inFunction;
|
|
616
|
+
suggestion = splitText(declaration, scoped, separator, functionNames, sourceCode);
|
|
617
|
+
}
|
|
618
|
+
for (const operand of substituted) {
|
|
619
|
+
context.report({
|
|
620
|
+
node: operand.node,
|
|
621
|
+
messageId: 'maskedExitStatus',
|
|
622
|
+
data: { builtin, name: operand.name },
|
|
623
|
+
suggest: suggestion === null
|
|
624
|
+
? []
|
|
625
|
+
: [
|
|
626
|
+
{
|
|
627
|
+
messageId: suggestion.messageId,
|
|
628
|
+
data: { names: substituted.map((s) => s.name).join(' ') },
|
|
629
|
+
fix: (fixer) => fixer.replaceTextRange(node.range, suggestion.text),
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
return {
|
|
636
|
+
File(node) {
|
|
637
|
+
functionNames = collectFunctionNames(node, new Set());
|
|
638
|
+
if (options.onlyWithErrexit === true) {
|
|
639
|
+
errexitBefore = errexitBeforeEachTopLevelStatement(node, sourceCode, options.allowShebangOnly === true);
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
DeclClause(node) {
|
|
643
|
+
const declaration = operandsOfDeclClause(node, sourceCode);
|
|
644
|
+
check(node, declaration.variant, declaration, true);
|
|
645
|
+
},
|
|
646
|
+
CallExpr(node) {
|
|
647
|
+
const invoked = sourceCode.resolveInvokedCommand(node, {
|
|
648
|
+
transparentWrappers: TRANSPARENT_WRAPPERS,
|
|
649
|
+
});
|
|
650
|
+
if (invoked?.effective.static !== true)
|
|
651
|
+
return;
|
|
652
|
+
const builtin = invoked.effective.text;
|
|
653
|
+
if (!ALL_BUILTINS.includes(builtin))
|
|
654
|
+
return;
|
|
655
|
+
const effectiveIndex = invoked.chainIndices.at(-1);
|
|
656
|
+
if (effectiveIndex === undefined)
|
|
657
|
+
return;
|
|
658
|
+
const declaration = declarationOfWords(builtin, node.args.slice(effectiveIndex + 1), sourceCode);
|
|
659
|
+
// A suggestion rewrites the whole command, which is only safe to
|
|
660
|
+
// rebuild when no wrapper precedes the builtin.
|
|
661
|
+
// Prefix assignments (`A=1 export x=$(c)`) belong to the command the
|
|
662
|
+
// replacement overwrites, so they too withhold the suggestion.
|
|
663
|
+
check(node, builtin, declaration, invoked.chain.length === 1 && node.assigns.length === 0);
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
},
|
|
667
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule's single options object.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export interface NoSecretEnvReadsOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Regex sources tested against each expanded variable's name. Replaces
|
|
9
|
+
* {@link DEFAULT_PATTERNS} entirely.
|
|
10
|
+
*/
|
|
11
|
+
readonly patterns?: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Command names whose arguments may read a secret-shaped variable. A
|
|
14
|
+
* command run by path matches by basename.
|
|
15
|
+
*/
|
|
16
|
+
readonly allowedSinks?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The `sh/no-secret-env-reads` rule module, registered as
|
|
20
|
+
* `no-secret-env-reads`.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare const noSecretEnvReads: import("./define-shell-rule.js").ShellRule<[NoSecretEnvReadsOptions?], "secretEnvRead">;
|