@eslint-sh/eslint-plugin 0.5.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 +228 -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 +51 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +53 -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-brace-sequence-padding-step.d.ts +15 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -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 +205 -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 +90 -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 +318 -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 +354 -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 +79 -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-brace-sequence-padding-step.md +90 -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 +131 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +87 -0
- package/docs/untrusted-code.md +440 -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,52 @@
|
|
|
1
|
+
import type { ShellFile, ShellNodeOf } from '../language/syntax-view.js';
|
|
2
|
+
import type { ShellSourceCode } from '../language/shell-source-code.js';
|
|
3
|
+
import type { SetOptionChange } from './set-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* The rule's single options object.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export interface RequireStrictModeOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The required prologue: either a `set` command (`"set -euo pipefail"`),
|
|
12
|
+
* whose enabled options become the requirement and whose text is what the
|
|
13
|
+
* autofix inserts, or the list of required long option names
|
|
14
|
+
* (`["errexit", "nounset"]`), for which the autofix inserts a `set` command
|
|
15
|
+
* spelled from that list. When omitted, it is `"set -eu"` for a file
|
|
16
|
+
* whose dialect is `posix` and `"set -euo pipefail"` for every other
|
|
17
|
+
* dialect.
|
|
18
|
+
*/
|
|
19
|
+
readonly mode?: string | readonly string[];
|
|
20
|
+
/**
|
|
21
|
+
* When `true`, options passed to the shell on the shebang line
|
|
22
|
+
* (`#!/bin/bash -e`, `#!/usr/bin/env -S bash -euo pipefail`) count as
|
|
23
|
+
* enabled, so a shebang alone can satisfy the rule. Off by default, because
|
|
24
|
+
* those options are ignored when the script is run as `bash script.sh`.
|
|
25
|
+
*/
|
|
26
|
+
readonly allowShebangOnly?: boolean;
|
|
27
|
+
/** How many top-level statements, from the top, the prologue may take to appear. */
|
|
28
|
+
readonly withinStatements?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether `stmt` is a plain, foreground, top-level `set` command, and if so
|
|
32
|
+
* the option changes its arguments make (read through `sourceCode`, so a
|
|
33
|
+
* non-static argument ends the reading).
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export declare function setStatementChanges(stmt: ShellNodeOf<'Stmt'>, sourceCode: ShellSourceCode): readonly SetOptionChange[];
|
|
38
|
+
/**
|
|
39
|
+
* The options a file's prologue leaves enabled: the state after the first
|
|
40
|
+
* `withinStatements` top-level statements of `file`, plus the shebang's
|
|
41
|
+
* options when `allowShebangOnly` is set. This is the single definition of
|
|
42
|
+
* "what the prologue enables", shared with other rules (such as
|
|
43
|
+
* `no-unchecked-cd`) that need to know whether strict mode is in force.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
export declare function prologueEnabledOptions(file: ShellFile, sourceCode: ShellSourceCode, config?: {
|
|
48
|
+
readonly allowShebangOnly?: boolean;
|
|
49
|
+
readonly withinStatements?: number;
|
|
50
|
+
}): ReadonlySet<string>;
|
|
51
|
+
/** Built-in rule `sh/require-strict-mode`. @internal */
|
|
52
|
+
export declare const requireStrictMode: import("./define-shell-rule.js").ShellRule<[RequireStrictModeOptions], "missingStrictMode">;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createRule } from './create-rule.js';
|
|
2
|
+
import { SET_OPTION_NAMES, applySetOptionChanges, formatSetCommand, parseSetArguments, shebangOptionChanges, } from './set-options.js';
|
|
3
|
+
import { lineBreakOf } from '../language/line-breaks.js';
|
|
4
|
+
/**
|
|
5
|
+
* The prologue an unconfigured `mode` falls back to for every dialect except
|
|
6
|
+
* `posix`.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_PROLOGUE = 'set -euo pipefail';
|
|
9
|
+
/**
|
|
10
|
+
* The prologue an unconfigured `mode` falls back to under the `posix`
|
|
11
|
+
* dialect: strict mode without `pipefail`, so the autofix never inserts a
|
|
12
|
+
* line that a pre-POSIX.1-2024 `sh` rejects. `pipefail` stays available
|
|
13
|
+
* there by configuring `mode`.
|
|
14
|
+
*/
|
|
15
|
+
const POSIX_DEFAULT_PROLOGUE = 'set -eu';
|
|
16
|
+
/**
|
|
17
|
+
* The prologue used when `mode` is not configured, chosen by the file's
|
|
18
|
+
* dialect. Only `posix` differs; `bats` files run under Bash and keep the
|
|
19
|
+
* Bash default.
|
|
20
|
+
*/
|
|
21
|
+
function defaultPrologueFor(dialect) {
|
|
22
|
+
return dialect === 'posix' ? POSIX_DEFAULT_PROLOGUE : DEFAULT_PROLOGUE;
|
|
23
|
+
}
|
|
24
|
+
/** How many top-level statements `withinStatements` defaults to. */
|
|
25
|
+
const DEFAULT_WITHIN_STATEMENTS = 5;
|
|
26
|
+
/**
|
|
27
|
+
* Turns the `mode` option into a {@link StrictModeRequirement}. A string mode
|
|
28
|
+
* must be a `set` command whose arguments only enable known options (no `+`
|
|
29
|
+
* flags, no positional parameters); anything else is a configuration error,
|
|
30
|
+
* reported by throwing so ESLint surfaces it instead of linting against a
|
|
31
|
+
* requirement nobody wrote.
|
|
32
|
+
*/
|
|
33
|
+
function resolveRequirement(mode) {
|
|
34
|
+
if (typeof mode !== 'string') {
|
|
35
|
+
return { required: [...new Set(mode)], prologue: formatSetCommand(mode) };
|
|
36
|
+
}
|
|
37
|
+
const prologue = mode.trim();
|
|
38
|
+
const [command, ...args] = prologue.split(/\s+/u);
|
|
39
|
+
const parsed = parseSetArguments(args);
|
|
40
|
+
if (command !== 'set' ||
|
|
41
|
+
parsed.stoppedBy !== undefined ||
|
|
42
|
+
parsed.changes.length === 0 ||
|
|
43
|
+
parsed.changes.some((change) => !change.enable)) {
|
|
44
|
+
throw new TypeError(`sh/require-strict-mode: \`mode\` must be a \`set\` command that only enables options ` +
|
|
45
|
+
`(for example "${DEFAULT_PROLOGUE}") or a list of option names; got ${JSON.stringify(mode)}.`);
|
|
46
|
+
}
|
|
47
|
+
return { required: [...new Set(parsed.changes.map((change) => change.option))], prologue };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether `stmt` is a plain, foreground, top-level `set` command, and if so
|
|
51
|
+
* the option changes its arguments make (read through `sourceCode`, so a
|
|
52
|
+
* non-static argument ends the reading).
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export function setStatementChanges(stmt, sourceCode) {
|
|
57
|
+
if (stmt.background)
|
|
58
|
+
return [];
|
|
59
|
+
const { cmd } = stmt;
|
|
60
|
+
if (cmd?.type !== 'CallExpr' || sourceCode.getCommandName(cmd) !== 'set')
|
|
61
|
+
return [];
|
|
62
|
+
const args = cmd.args.slice(1);
|
|
63
|
+
return parseSetArguments(args.map((word) => sourceCode.resolveWordText(word))).changes;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The options enabled after applying `seed`, then each statement's `set`
|
|
67
|
+
* changes in order.
|
|
68
|
+
*/
|
|
69
|
+
function enabledAfter(seed, statementChanges) {
|
|
70
|
+
const enabled = applySetOptionChanges(new Set(), seed);
|
|
71
|
+
for (const changes of statementChanges)
|
|
72
|
+
applySetOptionChanges(enabled, changes);
|
|
73
|
+
return enabled;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The options a file's prologue leaves enabled: the state after the first
|
|
77
|
+
* `withinStatements` top-level statements of `file`, plus the shebang's
|
|
78
|
+
* options when `allowShebangOnly` is set. This is the single definition of
|
|
79
|
+
* "what the prologue enables", shared with other rules (such as
|
|
80
|
+
* `no-unchecked-cd`) that need to know whether strict mode is in force.
|
|
81
|
+
*
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
export function prologueEnabledOptions(file, sourceCode, config = {}) {
|
|
85
|
+
const shebangChanges = config.allowShebangOnly === true ? shebangOptionChanges(sourceCode.text) : [];
|
|
86
|
+
const statements = file.stmts.slice(0, config.withinStatements ?? DEFAULT_WITHIN_STATEMENTS);
|
|
87
|
+
return enabledAfter(shebangChanges, statements.map((stmt) => setStatementChanges(stmt, sourceCode)));
|
|
88
|
+
}
|
|
89
|
+
/** Built-in rule `sh/require-strict-mode`. @internal */
|
|
90
|
+
export const requireStrictMode = createRule({
|
|
91
|
+
name: 'require-strict-mode',
|
|
92
|
+
meta: {
|
|
93
|
+
type: 'problem',
|
|
94
|
+
docs: {
|
|
95
|
+
description: 'require the configured strict-mode options (`set -euo pipefail` by default, `set -eu` for POSIX) near the top of the file',
|
|
96
|
+
},
|
|
97
|
+
fixable: 'code',
|
|
98
|
+
schema: [
|
|
99
|
+
{
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
mode: {
|
|
103
|
+
oneOf: [
|
|
104
|
+
{ type: 'string', minLength: 1 },
|
|
105
|
+
{
|
|
106
|
+
type: 'array',
|
|
107
|
+
items: { enum: [...SET_OPTION_NAMES] },
|
|
108
|
+
minItems: 1,
|
|
109
|
+
uniqueItems: true,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
allowShebangOnly: { type: 'boolean' },
|
|
114
|
+
withinStatements: { type: 'integer', minimum: 1 },
|
|
115
|
+
},
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
// `mode` has no static default: an unconfigured `mode` resolves per file
|
|
120
|
+
// from the dialect (see `defaultPrologueFor`), so it must stay absent here
|
|
121
|
+
// for the rule to tell "not configured" apart from a configured value.
|
|
122
|
+
defaultOptions: [
|
|
123
|
+
{
|
|
124
|
+
allowShebangOnly: false,
|
|
125
|
+
withinStatements: DEFAULT_WITHIN_STATEMENTS,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
messages: {
|
|
129
|
+
missingStrictMode: 'Enable strict mode with `{{prologue}}` within {{window}}; not enabled: {{missing}}.',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
create(context) {
|
|
133
|
+
const [options] = context.options;
|
|
134
|
+
const { sourceCode } = context;
|
|
135
|
+
const { required, prologue } = resolveRequirement(options.mode ?? defaultPrologueFor(sourceCode.dialect));
|
|
136
|
+
const withinStatements = options.withinStatements ?? DEFAULT_WITHIN_STATEMENTS;
|
|
137
|
+
return {
|
|
138
|
+
File(node) {
|
|
139
|
+
const text = sourceCode.text;
|
|
140
|
+
const shebangChanges = options.allowShebangOnly === true ? shebangOptionChanges(text) : [];
|
|
141
|
+
const statements = node.stmts.slice(0, withinStatements);
|
|
142
|
+
const statementChanges = statements.map((stmt) => setStatementChanges(stmt, sourceCode));
|
|
143
|
+
const enabled = enabledAfter(shebangChanges, statementChanges);
|
|
144
|
+
const missing = required.filter((option) => !enabled.has(option));
|
|
145
|
+
if (missing.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
// With the prologue inserted as the new first statement, the window
|
|
148
|
+
// holds one fewer original statement. Fix only if that would pass.
|
|
149
|
+
const prologueChanges = required.map((option) => ({ option, enable: true }));
|
|
150
|
+
const fixedEnabled = enabledAfter([...shebangChanges, ...prologueChanges], statementChanges.slice(0, withinStatements - 1));
|
|
151
|
+
const fixSatisfies = required.every((option) => fixedEnabled.has(option));
|
|
152
|
+
context.report({
|
|
153
|
+
loc: { line: 1, column: 1 },
|
|
154
|
+
messageId: 'missingStrictMode',
|
|
155
|
+
data: {
|
|
156
|
+
prologue,
|
|
157
|
+
window: withinStatements === 1
|
|
158
|
+
? 'the first statement'
|
|
159
|
+
: `the first ${String(withinStatements)} statements`,
|
|
160
|
+
missing: missing.join(', '),
|
|
161
|
+
},
|
|
162
|
+
fix: fixSatisfies
|
|
163
|
+
? (fixer) => {
|
|
164
|
+
const eol = lineBreakOf(text);
|
|
165
|
+
if (!text.startsWith('#!'))
|
|
166
|
+
return fixer.insertTextBeforeRange([0, 0], prologue + eol);
|
|
167
|
+
const shebangEnd = text.search(/\r\n|\n|\r/u);
|
|
168
|
+
return shebangEnd === -1
|
|
169
|
+
? fixer.insertTextAfterRange([0, text.length], eol + prologue)
|
|
170
|
+
: fixer.insertTextAfterRange([0, shebangEnd], eol + prologue);
|
|
171
|
+
}
|
|
172
|
+
: null,
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The options of `sh/require-tty-guard`.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export interface RequireTtyGuardOptions {
|
|
7
|
+
/** Replaces the default interactive commands (`read`, `select`). */
|
|
8
|
+
readonly commands?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Message ids of this rule. @internal */
|
|
11
|
+
export type RequireTtyGuardMessageId = 'unguardedInteractive' | 'wrapInTtyGuard';
|
|
12
|
+
/** Built-in rule `sh/require-tty-guard`. @internal */
|
|
13
|
+
export declare const requireTtyGuard: import("./define-shell-rule.js").ShellRule<[RequireTtyGuardOptions?], RequireTtyGuardMessageId>;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sh/require-tty-guard`: a command that waits for input from the terminal
|
|
3
|
+
* (`read`, `select`, or any configured command) must be guarded by a
|
|
4
|
+
* check that stdin is a terminal, or must have its input supplied explicitly.
|
|
5
|
+
*
|
|
6
|
+
* An unguarded interactive prompt hangs forever when a script runs without a
|
|
7
|
+
* terminal (CI, cron, an agent's tool call), and in automation it reads
|
|
8
|
+
* whatever happens to be on stdin. The rule is **purely syntactic**: it never
|
|
9
|
+
* tracks data or control flow, so its notion of "guarded" is deliberately
|
|
10
|
+
* shallow and exactly this:
|
|
11
|
+
*
|
|
12
|
+
* - **Guarded by a test.** The command is lexically inside the `then`/`else`
|
|
13
|
+
* body of an `if` whose condition contains a TTY test, or it is the right
|
|
14
|
+
* side of an `&&` whose left side contains one. A TTY test is `[ -t 0 ]`,
|
|
15
|
+
* `test -t 0` or `[[ -t 0 ]]`; only file descriptor `0` counts. A guard
|
|
16
|
+
* anywhere else (an earlier statement, a calling function, a `||` exit, a
|
|
17
|
+
* `while` condition) is not seen.
|
|
18
|
+
* - **Input supplied explicitly.** The command, or any enclosing statement
|
|
19
|
+
* (so `while read …; done < file` is covered), redirects fd 0 from a file,
|
|
20
|
+
* here-document, or here-string; or it is the right side of a pipe; or it is
|
|
21
|
+
* a `read -u N` from a descriptor other than 0.
|
|
22
|
+
*
|
|
23
|
+
* `select` is parsed as a loop, not a command, so it is recognized from the
|
|
24
|
+
* loop node. Command names are read through `ShellSourceCode.getCommandName`,
|
|
25
|
+
* so a dynamic command word is never matched.
|
|
26
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
import { DashHdoc, Hdoc } from 'sh-ast/analyze';
|
|
34
|
+
import { createRule } from './create-rule.js';
|
|
35
|
+
import { lineBreakOf } from '../language/line-breaks.js';
|
|
36
|
+
/** Registry name of this rule. */
|
|
37
|
+
const RULE_NAME = 'require-tty-guard';
|
|
38
|
+
/** The interactive commands checked when `commands` is not configured. */
|
|
39
|
+
const DEFAULT_COMMANDS = ['read', 'select'];
|
|
40
|
+
/**
|
|
41
|
+
* Operator codes of the faithful syntax tree (`sh-ast`'s `BinCmdOperator` and
|
|
42
|
+
* `RedirOperator` values, which the lint view preserves as numbers).
|
|
43
|
+
*/
|
|
44
|
+
const AND_STMT = 11;
|
|
45
|
+
const OR_STMT = 12;
|
|
46
|
+
const PIPE = 13;
|
|
47
|
+
const PIPE_ALL = 14;
|
|
48
|
+
const REDIRECT_INPUT_OPS = new Set([
|
|
49
|
+
65, // `<`
|
|
50
|
+
66, // `<>`
|
|
51
|
+
67, // `<&`
|
|
52
|
+
71, // `<<`
|
|
53
|
+
72, // `<<-`
|
|
54
|
+
73, // `<<<`
|
|
55
|
+
]);
|
|
56
|
+
const DUP_INPUT = 67;
|
|
57
|
+
/** The `-t` unary test operator (`sh-ast`'s `TsFdTerm`). */
|
|
58
|
+
const TS_FD_TERM = 123;
|
|
59
|
+
/**
|
|
60
|
+
* Read options that consume an argument, so the next word is not a variable
|
|
61
|
+
* name or another option cluster.
|
|
62
|
+
*/
|
|
63
|
+
const READ_ARG_FLAGS = new Set(['a', 'd', 'i', 'n', 'N', 'p', 't', 'u']);
|
|
64
|
+
/** The static text of `node` if it is a `Word` with a known value. */
|
|
65
|
+
function textOf(sourceCode, node) {
|
|
66
|
+
return node?.type === 'Word' ? sourceCode.resolveWordText(node) : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether `call` is a `[ … -t 0 … ]` or `test … -t 0 …` command. `-t 0` must
|
|
70
|
+
* appear as two adjacent static words.
|
|
71
|
+
*/
|
|
72
|
+
function isCommandTtyTest(call, sourceCode) {
|
|
73
|
+
const name = sourceCode.getCommandName(call);
|
|
74
|
+
if (name !== '[' && name !== 'test')
|
|
75
|
+
return false;
|
|
76
|
+
const words = call.args.map((word) => textOf(sourceCode, word));
|
|
77
|
+
return words.some((word, i) => word === '-t' && words[i + 1] === '0');
|
|
78
|
+
}
|
|
79
|
+
/** Whether `test` is a `[[ -t 0 ]]` unary test. */
|
|
80
|
+
function isClauseTtyTest(test, sourceCode) {
|
|
81
|
+
return test.op === TS_FD_TERM && textOf(sourceCode, test.x) === '0';
|
|
82
|
+
}
|
|
83
|
+
/** Whether `inner` lies within `outer`, by source range. */
|
|
84
|
+
function within(inner, outer) {
|
|
85
|
+
return inner.range[0] >= outer[0] && inner.range[1] <= outer[1];
|
|
86
|
+
}
|
|
87
|
+
/** The source range covering a list of statements, or `null` if empty. */
|
|
88
|
+
function listRange(stmts) {
|
|
89
|
+
if (stmts.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
return [stmts[0].range[0], stmts[stmts.length - 1].range[1]];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether a redirect on a statement feeds fd 0 from an explicit source:
|
|
95
|
+
* `< file`, `0< file`, `<<`, `<<<`, `<> file`, or `<&N` for `N` other than 0.
|
|
96
|
+
*/
|
|
97
|
+
function redirectsStdin(stmt, sourceCode) {
|
|
98
|
+
return stmt.redirs.some((redir) => {
|
|
99
|
+
if (!REDIRECT_INPUT_OPS.has(redir.op))
|
|
100
|
+
return false;
|
|
101
|
+
const fd = redir.n === undefined ? '0' : redir.n.value;
|
|
102
|
+
if (fd !== '0')
|
|
103
|
+
return false;
|
|
104
|
+
return redir.op !== DUP_INPUT || textOf(sourceCode, redir.word) !== '0';
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a `read` takes input from a descriptor other than 0 (`-u 3`,
|
|
109
|
+
* `-u3`, `-ru 3`). A non-static descriptor counts as non-zero, because the
|
|
110
|
+
* rule cannot show it is stdin. Option parsing stops at `--` or the first
|
|
111
|
+
* non-option word.
|
|
112
|
+
*/
|
|
113
|
+
function readsFromOtherFd(call, sourceCode) {
|
|
114
|
+
const words = call.args.slice(1);
|
|
115
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
116
|
+
const text = textOf(sourceCode, words[i]);
|
|
117
|
+
if (text === null || !text.startsWith('-') || text === '-' || text === '--')
|
|
118
|
+
return false;
|
|
119
|
+
for (let j = 1; j < text.length; j += 1) {
|
|
120
|
+
const flag = text.charAt(j);
|
|
121
|
+
if (!READ_ARG_FLAGS.has(flag))
|
|
122
|
+
continue;
|
|
123
|
+
const rest = text.slice(j + 1);
|
|
124
|
+
if (flag === 'u') {
|
|
125
|
+
const fd = rest === '' ? textOf(sourceCode, words[i + 1]) : rest;
|
|
126
|
+
return fd !== '0';
|
|
127
|
+
}
|
|
128
|
+
if (rest === '')
|
|
129
|
+
i += 1;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether the exit status of `stmt` steers control flow: it is a condition of
|
|
137
|
+
* `if`/`while`/`until`, or an operand of `&&`/`||`. Wrapping such a statement
|
|
138
|
+
* in `if … fi` would replace its status with the `if`'s (0 when the guard
|
|
139
|
+
* fails), changing which way the construct goes, so no suggestion is offered.
|
|
140
|
+
*/
|
|
141
|
+
function isControlFlowOperand(stmt, sourceCode) {
|
|
142
|
+
const parent = sourceCode.getParent(stmt);
|
|
143
|
+
if (parent === undefined)
|
|
144
|
+
return false;
|
|
145
|
+
if (parent.type === 'BinaryCmd')
|
|
146
|
+
return parent.op === AND_STMT || parent.op === OR_STMT;
|
|
147
|
+
if (parent.type === 'IfClause' || parent.type === 'WhileClause') {
|
|
148
|
+
return [...parent.cond, ...parent.condlast].includes(stmt);
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/** Redirection operators that open a here-document (`<<`, `<<-`), whose body follows the line. */
|
|
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
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The text that replaces `stmtText` (a statement starting at `start`) with
|
|
176
|
+
* the statement inside `if [ -t 0 ]; then … fi`.
|
|
177
|
+
*
|
|
178
|
+
* A statement that starts its own line and spans several is wrapped on lines
|
|
179
|
+
* of its own. Anywhere else no line break goes before the statement: an
|
|
180
|
+
* earlier command on the same line may have opened a here-document, whose
|
|
181
|
+
* body begins at the next line break, so one inserted there would make the
|
|
182
|
+
* rest of the wrapper part of that body. A line break goes before `fi` only
|
|
183
|
+
* when the statement ends with a here-document of its own, whose terminator
|
|
184
|
+
* must stay alone on its line.
|
|
185
|
+
*/
|
|
186
|
+
function wrapInGuard(text, start, stmtText, endsInHeredoc) {
|
|
187
|
+
const eol = lineBreakOf(text);
|
|
188
|
+
const startsLine = /^[ \t]*$/u.test(text.slice(text.lastIndexOf('\n', start - 1) + 1, start));
|
|
189
|
+
if (startsLine && stmtText.includes('\n'))
|
|
190
|
+
return `if [ -t 0 ]; then${eol}${stmtText}${eol}fi`;
|
|
191
|
+
return `if [ -t 0 ]; then ${stmtText}${endsInHeredoc ? eol : '; '}fi`;
|
|
192
|
+
}
|
|
193
|
+
/** Built-in rule `sh/require-tty-guard`. @internal */
|
|
194
|
+
export const requireTtyGuard = createRule({
|
|
195
|
+
name: RULE_NAME,
|
|
196
|
+
meta: {
|
|
197
|
+
type: 'suggestion',
|
|
198
|
+
docs: {
|
|
199
|
+
description: 'Require interactive commands such as `read` to be guarded by `[ -t 0 ]`',
|
|
200
|
+
},
|
|
201
|
+
hasSuggestions: true,
|
|
202
|
+
schema: [
|
|
203
|
+
{
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
commands: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
207
|
+
},
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
messages: {
|
|
212
|
+
unguardedInteractive: '`{{command}}` waits for terminal input and hangs when stdin is not a terminal. Guard it with `[ -t 0 ]` or supply its input with a redirection.',
|
|
213
|
+
wrapInTtyGuard: 'Wrap the statement in `if [ -t 0 ]; then … fi`.',
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
create(context) {
|
|
217
|
+
const { sourceCode } = context;
|
|
218
|
+
const commands = new Set(context.options[0]?.commands ?? DEFAULT_COMMANDS);
|
|
219
|
+
// Interactive commands found in the file, checked once every guard is known.
|
|
220
|
+
const candidates = [];
|
|
221
|
+
const guards = [];
|
|
222
|
+
/** Whether any TTY test lies inside `range`. */
|
|
223
|
+
const guardIn = (range) => range !== null && guards.some((guard) => within(guard, range));
|
|
224
|
+
/** Whether `node` is a statement-list child inside a guarding `if`/`&&`. */
|
|
225
|
+
const isGuarded = (node) => {
|
|
226
|
+
let child = node;
|
|
227
|
+
for (let parent = sourceCode.getParent(child); parent; parent = sourceCode.getParent(child)) {
|
|
228
|
+
if (parent.type === 'IfClause') {
|
|
229
|
+
const cond = listRange([...parent.cond, ...parent.condlast]);
|
|
230
|
+
const inCond = cond !== null && within(child, cond);
|
|
231
|
+
if (!inCond && guardIn(cond))
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
else if (parent.type === 'BinaryCmd' && parent.op === AND_STMT) {
|
|
235
|
+
if (parent.y === child && parent.x !== undefined && guardIn(parent.x.range))
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
child = parent;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
/** Whether stdin is supplied to `node` by a redirect or a pipe. */
|
|
243
|
+
const hasExplicitInput = (node) => {
|
|
244
|
+
let child = node;
|
|
245
|
+
for (let parent = sourceCode.getParent(child); parent; parent = sourceCode.getParent(child)) {
|
|
246
|
+
if (parent.type === 'Stmt' && redirectsStdin(parent, sourceCode))
|
|
247
|
+
return true;
|
|
248
|
+
// A coprocess body reads the coprocess's own input pipe, never the
|
|
249
|
+
// terminal, so a terminal guard is meaningless there.
|
|
250
|
+
if (parent.type === 'CoprocClause')
|
|
251
|
+
return true;
|
|
252
|
+
if (parent.type === 'BinaryCmd' &&
|
|
253
|
+
(parent.op === PIPE || parent.op === PIPE_ALL) &&
|
|
254
|
+
parent.y === child) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
child = parent;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
CallExpr(node) {
|
|
263
|
+
if (isCommandTtyTest(node, sourceCode))
|
|
264
|
+
guards.push(node);
|
|
265
|
+
const name = sourceCode.getCommandName(node);
|
|
266
|
+
if (name === null || !commands.has(name))
|
|
267
|
+
return;
|
|
268
|
+
if (name === 'read' && readsFromOtherFd(node, sourceCode))
|
|
269
|
+
return;
|
|
270
|
+
candidates.push({ node, name });
|
|
271
|
+
},
|
|
272
|
+
UnaryTest(node) {
|
|
273
|
+
if (isClauseTtyTest(node, sourceCode))
|
|
274
|
+
guards.push(node);
|
|
275
|
+
},
|
|
276
|
+
ForClause(node) {
|
|
277
|
+
if (node.select && commands.has('select')) {
|
|
278
|
+
candidates.push({ node, name: 'select' });
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
'File:exit'() {
|
|
282
|
+
for (const { node, name } of candidates) {
|
|
283
|
+
if (hasExplicitInput(node) || isGuarded(node))
|
|
284
|
+
continue;
|
|
285
|
+
const stmt = sourceCode.getParent(node);
|
|
286
|
+
// A statement that runs in the background cannot be wrapped without
|
|
287
|
+
// changing what is backgrounded, so it gets no suggestion.
|
|
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);
|
|
294
|
+
context.report({
|
|
295
|
+
node,
|
|
296
|
+
messageId: 'unguardedInteractive',
|
|
297
|
+
data: { command: name },
|
|
298
|
+
suggest: wrappable
|
|
299
|
+
? [
|
|
300
|
+
{
|
|
301
|
+
messageId: 'wrapInTtyGuard',
|
|
302
|
+
fix: (fixer) => {
|
|
303
|
+
// A statement's own range also covers a trailing `;`, which
|
|
304
|
+
// must stay outside the wrapper, so the end is taken from
|
|
305
|
+
// the command and its redirections.
|
|
306
|
+
const end = Math.max(...(stmt.cmd === undefined ? [] : [stmt.cmd.range[1]]), ...stmt.redirs.map((redir) => redir.range[1]));
|
|
307
|
+
const body = wrapInGuard(sourceCode.text, stmt.range[0], sourceCode.text.slice(stmt.range[0], end), stmt.redirs.some((redir) => HEREDOC_OPERATORS.has(redir.op)));
|
|
308
|
+
return fixer.replaceTextRange([stmt.range[0], end], body);
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
: [],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding resolution for the declaration-convention extension point: the one
|
|
3
|
+
* place a rule finds out which {@link DeclarationConvention} the consumer has
|
|
4
|
+
* bound, if any.
|
|
5
|
+
*
|
|
6
|
+
* Every rule that consumes a convention calls
|
|
7
|
+
* {@link resolveDeclarationConvention} from its `create()`, so all of them
|
|
8
|
+
* agree on precedence, on what "not configured" means, and on how a
|
|
9
|
+
* misconfiguration is reported. The precedence is:
|
|
10
|
+
*
|
|
11
|
+
* 1. the rule's own `declarationConvention` option (its first options
|
|
12
|
+
* object), so one rule can be pointed at a different convention. ESLint
|
|
13
|
+
* flat config copies rule options with `structuredClone`, so a convention
|
|
14
|
+
* whose methods are own properties is rejected there by ESLint itself
|
|
15
|
+
* ("could not be cloned"), and a class instance arrives with its methods
|
|
16
|
+
* stripped and fails the check below. In practice consumers bind through
|
|
17
|
+
* settings;
|
|
18
|
+
* 2. the shared `settings.sh.declarationConvention`, bound once for every
|
|
19
|
+
* consuming rule;
|
|
20
|
+
* 3. otherwise `null`: no convention is bound and the consuming rule is
|
|
21
|
+
* inert — it reports nothing. Shipping a default would smuggle one
|
|
22
|
+
* project's declaration policy into every project.
|
|
23
|
+
*
|
|
24
|
+
* Only an absent (`undefined`) value falls through to the next source. Any
|
|
25
|
+
* other value is the consumer's explicit choice and must be a convention; if
|
|
26
|
+
* it is not, resolution throws an error that names the offending setting, so
|
|
27
|
+
* the misconfiguration surfaces as a readable configuration message rather
|
|
28
|
+
* than a `TypeError` from deep inside a rule.
|
|
29
|
+
*
|
|
30
|
+
* The resolver is public so a rule pack can write its own consumer of the
|
|
31
|
+
* bound convention and get exactly the precedence, validation and error text
|
|
32
|
+
* the built-in consumers (`sh/no-undeclared-commands`,
|
|
33
|
+
* `sh/no-unused-declarations`) use, instead of re-implementing them.
|
|
34
|
+
*/
|
|
35
|
+
import type { SettingsConfig } from '@eslint/core';
|
|
36
|
+
import type { DeclarationConvention } from '../declaration-conventions/declaration-convention.js';
|
|
37
|
+
/**
|
|
38
|
+
* The slice of an ESLint rule context that binding resolution reads: the rule
|
|
39
|
+
* id (for error messages), its configured options, and the shared settings.
|
|
40
|
+
* Any rule `context` satisfies it, so a rule passes its own `context`.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
export interface DeclarationConventionBindingContext {
|
|
45
|
+
/** The rule id as configured (`pack/my-rule`), named in an option-binding error. */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
/** The rule's configured options; the first may carry `declarationConvention`. */
|
|
48
|
+
readonly options: readonly unknown[];
|
|
49
|
+
/** The shared settings; `settings.sh.declarationConvention` is the usual binding. */
|
|
50
|
+
readonly settings: SettingsConfig;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolves the declaration convention bound for the rule running in
|
|
54
|
+
* `context`: the rule's `declarationConvention` option, else
|
|
55
|
+
* `settings.sh.declarationConvention`, else `null` (the rule is inert).
|
|
56
|
+
*
|
|
57
|
+
* Throws an `Error` naming the offending setting when the configured value is
|
|
58
|
+
* present but does not implement `declaredCommands` (or has a non-function
|
|
59
|
+
* `removeDeclaration`). The message names where the value came from and what
|
|
60
|
+
* it was, for example one beginning
|
|
61
|
+
* `Invalid settings.sh.declarationConvention: expected a declaration convention`
|
|
62
|
+
* and ending `but got string "jq".` Call it from `create()`, so the error
|
|
63
|
+
* surfaces as ESLint's
|
|
64
|
+
* configuration message for the file being linted.
|
|
65
|
+
*
|
|
66
|
+
* A rule that receives `null` should register no listeners: with no
|
|
67
|
+
* convention bound, a convention consumer has nothing to check.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import {
|
|
72
|
+
* defineShellRule,
|
|
73
|
+
* readDeclaredCommands,
|
|
74
|
+
* resolveDeclarationConvention,
|
|
75
|
+
* } from '@eslint-sh/eslint-plugin';
|
|
76
|
+
*
|
|
77
|
+
* export const maxDeclaredCommands = defineShellRule({
|
|
78
|
+
* meta: { schema: [], messages: { tooMany: 'Declare at most 5 commands.' } },
|
|
79
|
+
* create(context) {
|
|
80
|
+
* const convention = resolveDeclarationConvention(context);
|
|
81
|
+
* if (convention === null) return {};
|
|
82
|
+
* return {
|
|
83
|
+
* File(node) {
|
|
84
|
+
* // Validated declarations for the file `context` is linting.
|
|
85
|
+
* if (readDeclaredCommands(convention, context).length > 5) {
|
|
86
|
+
* context.report({ node, messageId: 'tooMany' });
|
|
87
|
+
* }
|
|
88
|
+
* },
|
|
89
|
+
* };
|
|
90
|
+
* },
|
|
91
|
+
* });
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveDeclarationConvention(context: DeclarationConventionBindingContext): DeclarationConvention | null;
|