@eslint-sh/eslint-plugin 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/dist/command-harvest/builtins/bash.d.ts +1 -1
- package/dist/command-harvest/builtins/bash.js +1 -1
- package/dist/command-harvest/builtins/bats.d.ts +2 -0
- package/dist/command-harvest/builtins/bats.js +3 -0
- package/dist/command-harvest/wrapper-table.d.ts +3 -19
- package/dist/command-harvest/wrapper-table.js +8 -38
- package/dist/configs/bash-3-2.d.ts +7 -6
- package/dist/configs/bash-3-2.js +4 -3
- package/dist/configs/posix-portable.d.ts +1 -1
- package/dist/declaration-conventions/read-declared-commands.d.ts +2 -2
- package/dist/declaration-conventions/read-declared-commands.js +2 -2
- package/dist/eslint-plugin-syntax.d.ts +4 -0
- package/dist/eslint-plugin.d.ts +86 -40
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/language/shell-language-options.d.ts +18 -15
- package/dist/language/shell-language-options.js +33 -26
- package/dist/language/shell-language.d.ts +10 -9
- package/dist/language/shell-language.js +18 -35
- package/dist/language/shell-source-code.d.ts +17 -9
- package/dist/language/shell-source-code.js +53 -12
- package/dist/rules/bash-variable-semantics.d.ts +22 -0
- package/dist/rules/bash-variable-semantics.js +41 -0
- package/dist/rules/compat/create-compat-rule.js +1 -1
- package/dist/rules/compat/declaration-options.d.ts +2 -2
- package/dist/rules/compat/declaration-options.js +5 -2
- package/dist/rules/compat/no-append-all-redirect.js +1 -1
- package/dist/rules/compat/no-associative-arrays.js +2 -2
- package/dist/rules/compat/no-brace-sequence-padding-step.js +4 -4
- package/dist/rules/compat/no-case-attributes.js +3 -3
- package/dist/rules/compat/no-case-fallthrough.js +2 -2
- package/dist/rules/compat/no-case-modification.js +2 -2
- package/dist/rules/compat/no-coproc.js +1 -1
- package/dist/rules/compat/no-declare-g.js +3 -3
- package/dist/rules/compat/no-double-bracket.js +2 -2
- package/dist/rules/compat/no-epoch-variables.js +2 -2
- package/dist/rules/compat/no-fd-variable.js +1 -1
- package/dist/rules/compat/no-globstar.js +1 -1
- package/dist/rules/compat/no-lastpipe-semantics.js +2 -2
- package/dist/rules/compat/no-let.js +2 -1
- package/dist/rules/compat/no-mapfile.js +4 -2
- package/dist/rules/compat/no-nameref.js +3 -3
- package/dist/rules/compat/no-negative-array-index.js +39 -9
- package/dist/rules/compat/no-nounset-empty-array.js +15 -4
- package/dist/rules/compat/no-parameter-transformation.js +2 -2
- package/dist/rules/compat/no-pipe-all.js +1 -1
- package/dist/rules/compat/no-printf-time-format.js +4 -2
- package/dist/rules/compat/no-source.js +2 -1
- package/dist/rules/compat/no-test-v.js +4 -2
- package/dist/rules/compat/no-unicode-escape.js +2 -2
- package/dist/rules/compat/no-wait-n.js +4 -2
- package/dist/rules/compat/nounset-array-proof.d.ts +8 -0
- package/dist/rules/compat/nounset-array-proof.js +563 -0
- package/dist/rules/compat/posix-absent-command.d.ts +7 -8
- package/dist/rules/compat/posix-absent-command.js +17 -15
- package/dist/rules/compat/shopt-options.js +3 -1
- package/dist/rules/compat/version-gates.d.ts +9 -9
- package/dist/rules/compat/version-gates.js +13 -13
- package/dist/rules/create-wrapper-rule.js +12 -7
- package/dist/rules/no-dynamic-invocation.d.ts +0 -8
- package/dist/rules/no-dynamic-invocation.js +7 -15
- package/dist/rules/no-eval.js +3 -1
- package/dist/rules/no-masked-exit-status.js +15 -8
- package/dist/rules/no-secret-env-reads.d.ts +4 -3
- package/dist/rules/no-secret-env-reads.js +37 -14
- package/dist/rules/no-secret-flags.d.ts +2 -1
- package/dist/rules/no-secret-flags.js +7 -8
- package/dist/rules/no-unchecked-cd.js +3 -1
- package/dist/rules/no-undeclared-commands.d.ts +1 -15
- package/dist/rules/no-undeclared-commands.js +3 -15
- package/dist/rules/no-unused-declarations.d.ts +1 -18
- package/dist/rules/no-unused-declarations.js +9 -34
- package/dist/rules/prefer-local.js +4 -3
- package/dist/rules/quote-expansions.js +3 -28
- package/dist/rules/require-network-wrapper.d.ts +2 -17
- package/dist/rules/require-network-wrapper.js +33 -4
- package/dist/rules/resolve-declaration-convention.d.ts +5 -6
- package/dist/rules/resolve-declaration-convention.js +5 -6
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +57 -26
- package/dist/rules/resolve-transparent-wrappers.d.ts +43 -0
- package/dist/rules/resolve-transparent-wrappers.js +98 -0
- package/dist/rules/set-options.d.ts +2 -1
- package/dist/rules/set-options.js +17 -5
- package/dist/rules/shebang-interpreter.js +4 -2
- package/dist/rules/transparent-wrappers.d.ts +4 -3
- package/dist/rules/transparent-wrappers.js +5 -4
- package/dist/rules/user-pattern.d.ts +5 -3
- package/dist/rules/user-pattern.js +6 -4
- package/dist/syntax/index.d.ts +4 -0
- package/dist/syntax/index.js +4 -0
- package/docs/compat.md +76 -27
- package/docs/custom-rule-tutorial.md +9 -3
- package/docs/examples/no-listed-commands-rule.ts +2 -2
- package/docs/rules/README.md +36 -4
- package/docs/rules/_template.md +2 -2
- package/docs/rules/compat/no-append-all-redirect.md +12 -12
- package/docs/rules/compat/no-associative-arrays.md +14 -14
- package/docs/rules/compat/no-brace-sequence-padding-step.md +12 -12
- package/docs/rules/compat/no-case-attributes.md +13 -13
- package/docs/rules/compat/no-case-fallthrough.md +12 -12
- package/docs/rules/compat/no-case-modification.md +11 -11
- package/docs/rules/compat/no-coproc.md +13 -13
- package/docs/rules/compat/no-declare-g.md +13 -13
- package/docs/rules/compat/no-double-bracket.md +2 -2
- package/docs/rules/compat/no-epoch-variables.md +13 -13
- package/docs/rules/compat/no-fd-variable.md +12 -12
- package/docs/rules/compat/no-globstar.md +13 -13
- package/docs/rules/compat/no-lastpipe-semantics.md +13 -13
- package/docs/rules/compat/no-let.md +2 -2
- package/docs/rules/compat/no-mapfile.md +16 -16
- package/docs/rules/compat/no-nameref.md +13 -13
- package/docs/rules/compat/no-negative-array-index.md +14 -14
- package/docs/rules/compat/no-nounset-empty-array.md +72 -17
- package/docs/rules/compat/no-parameter-transformation.md +19 -19
- package/docs/rules/compat/no-pipe-all.md +13 -13
- package/docs/rules/compat/no-printf-time-format.md +13 -13
- package/docs/rules/compat/no-source.md +2 -2
- package/docs/rules/compat/no-test-v.md +12 -12
- package/docs/rules/compat/no-unicode-escape.md +13 -13
- package/docs/rules/compat/no-wait-n.md +13 -13
- package/docs/rules/no-dynamic-invocation.md +17 -12
- package/docs/rules/no-eval.md +6 -1
- package/docs/rules/no-masked-exit-status.md +4 -0
- package/docs/rules/no-secret-env-reads.md +34 -3
- package/docs/rules/no-secret-flags.md +19 -2
- package/docs/rules/no-unchecked-cd.md +9 -2
- package/docs/rules/no-undeclared-commands.md +16 -15
- package/docs/rules/no-unused-declarations.md +17 -20
- package/docs/rules/prefer-local.md +20 -2
- package/docs/rules/quote-expansions.md +8 -5
- package/docs/rules/require-network-wrapper.md +22 -17
- package/docs/rules/require-shebang.md +10 -4
- package/docs/rules/require-strict-mode.md +6 -0
- package/docs/shellcheck.md +9 -5
- package/docs/transparent-wrappers.md +97 -0
- package/docs/wrapper-rules.md +9 -0
- package/package.json +2 -2
- package/dist/language/end-of-input-marker.d.ts +0 -29
- package/dist/language/end-of-input-marker.js +0 -88
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
|
|
2
2
|
import { harvestCommands } from '../command-harvest/harvest-commands.js';
|
|
3
3
|
import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
|
|
4
4
|
import { createRule } from './create-rule.js';
|
|
5
5
|
import { readDeclaredCommands } from '../declaration-conventions/read-declared-commands.js';
|
|
6
6
|
import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
|
|
7
|
-
import { networkWrapperSpec } from './resolve-network-wrapper.js';
|
|
8
7
|
import { SHELL_WRAPPER } from './transparent-wrappers.js';
|
|
9
8
|
/** The registry name of this rule (`sh/no-unused-declarations` in a user's config). */
|
|
10
9
|
const RULE_NAME = 'no-unused-declarations';
|
|
@@ -54,29 +53,17 @@ function trapSetsCodeString(operands) {
|
|
|
54
53
|
const SHELL_NAMES = new Set(SHELL_WRAPPER.names);
|
|
55
54
|
/** The builtins that run another file's code in this shell: POSIX `.` and Bash's `source`. */
|
|
56
55
|
const SOURCE_COMMANDS = new Set(['.', 'source']);
|
|
57
|
-
/**
|
|
58
|
-
* The wrappers whose path forms (`/bin/bash`, `/usr/bin/env`) the completeness
|
|
59
|
-
* check also follows, by basename: shells invoked as commands and `env`,
|
|
60
|
-
* the wrappers that can run an embedded command string. sh-ast matches
|
|
61
|
-
* wrapper names exactly, so without this `/bin/bash -c '…'` would resolve to
|
|
62
|
-
* the static command `/bin/bash`.
|
|
63
|
-
*/
|
|
64
|
-
const PATH_FOLLOWED_WRAPPERS = [
|
|
65
|
-
SHELL_WRAPPER,
|
|
66
|
-
...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => spec.names.includes('env')),
|
|
67
|
-
];
|
|
68
56
|
/** The basename of `text` when it is a path (contains a slash), else `null`. */
|
|
69
57
|
function pathBasename(text) {
|
|
70
58
|
const slash = text.lastIndexOf('/');
|
|
71
59
|
return slash === -1 ? null : text.slice(slash + 1);
|
|
72
60
|
}
|
|
73
61
|
/**
|
|
74
|
-
* The effective command of `site` under
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* path, and a site has finitely many words, so the loop ends.
|
|
62
|
+
* The effective command of `site` under the resolved completeness `table`.
|
|
63
|
+
* Relative utility paths use only specs already in that table, including its
|
|
64
|
+
* shell-interpreter entries. Recreating a built-in spec here would undo a
|
|
65
|
+
* project's exact-name override and make completeness disagree with usage.
|
|
66
|
+
* Each round adds a new path from the finite argv, so the loop ends.
|
|
80
67
|
*/
|
|
81
68
|
function resolveEffective(site, table) {
|
|
82
69
|
let current = table;
|
|
@@ -89,7 +76,7 @@ function resolveEffective(site, table) {
|
|
|
89
76
|
const base = pathBasename(path);
|
|
90
77
|
if (base === null || current.some((spec) => spec.names.includes(path)))
|
|
91
78
|
return resolution;
|
|
92
|
-
const spec =
|
|
79
|
+
const spec = current.find((entry) => entry.shellBuiltin !== true && entry.names.includes(base));
|
|
93
80
|
if (spec === undefined)
|
|
94
81
|
return resolution;
|
|
95
82
|
current = [{ ...spec, names: [path] }, ...current];
|
|
@@ -177,19 +164,7 @@ export const noUnusedDeclarations = createRule({
|
|
|
177
164
|
description: 'Disallow declaring external commands that a script never uses, per the configured declaration convention',
|
|
178
165
|
},
|
|
179
166
|
fixable: 'code',
|
|
180
|
-
schema: [
|
|
181
|
-
{
|
|
182
|
-
type: 'object',
|
|
183
|
-
properties: {
|
|
184
|
-
transparentWrappers: {
|
|
185
|
-
type: 'array',
|
|
186
|
-
items: { type: 'string', minLength: 1 },
|
|
187
|
-
uniqueItems: true,
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
additionalProperties: false,
|
|
191
|
-
},
|
|
192
|
-
],
|
|
167
|
+
schema: [],
|
|
193
168
|
messages: {
|
|
194
169
|
unusedDeclaration: "'{{command}}' is declared but never used. Remove the declaration so the script's stated dependencies match what it runs.",
|
|
195
170
|
unusedFileDeclaration: "'{{command}}' is declared in {{source}} but this script never uses it. Remove it there so the script's stated dependencies match what it runs.",
|
|
@@ -202,7 +177,7 @@ export const noUnusedDeclarations = createRule({
|
|
|
202
177
|
return {};
|
|
203
178
|
}
|
|
204
179
|
const { sourceCode } = context;
|
|
205
|
-
const transparentWrappers =
|
|
180
|
+
const transparentWrappers = resolveTransparentWrappers(context, harvestWrapperTable(sourceCode.dialect));
|
|
206
181
|
// The completeness check follows the harvest's table plus shells
|
|
207
182
|
// invoked as commands, so `bash -c '…'` and `sh -c '…'` resolve to an
|
|
208
183
|
// embedded command (as in `no-dynamic-invocation`) instead of stopping
|
|
@@ -165,12 +165,13 @@ export const preferLocal = createRule({
|
|
|
165
165
|
},
|
|
166
166
|
create(context) {
|
|
167
167
|
const { sourceCode } = context;
|
|
168
|
-
// `local` is not POSIX: under `posix` there is nothing to declare with.
|
|
169
|
-
if (sourceCode.dialect === 'posix')
|
|
170
|
-
return {};
|
|
171
168
|
const [options] = context.options;
|
|
172
169
|
const allowed = new Set(options.allow ?? []);
|
|
173
170
|
const allowPattern = compileUserPattern('sh/prefer-local', 'allowPattern', options.allowPattern ?? DEFAULT_ALLOW_PATTERN, 'u');
|
|
171
|
+
// Validate configuration in every dialect, even where the rule is inert.
|
|
172
|
+
// `local` is not POSIX: under `posix` there is nothing to declare with.
|
|
173
|
+
if (sourceCode.dialect === 'posix')
|
|
174
|
+
return {};
|
|
174
175
|
const text = sourceCode.text;
|
|
175
176
|
const lastpipe = enablesLastpipe(sourceCode.ast, sourceCode);
|
|
176
177
|
// Names assigned or declared outside every function, with where each
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { DashHdoc, Hdoc } from 'sh-ast/analyze';
|
|
42
42
|
import { createRule } from './create-rule.js';
|
|
43
|
+
import { isBashIntegerVariable } from './bash-variable-semantics.js';
|
|
43
44
|
import { messageExcerpt } from '../language/line-breaks.js';
|
|
44
45
|
/** The registry name of this rule (`sh/quote-expansions` in a user's config). */
|
|
45
46
|
const RULE_NAME = 'quote-expansions';
|
|
@@ -50,33 +51,7 @@ const RULE_NAME = 'quote-expansions';
|
|
|
50
51
|
* their values are arbitrary text.
|
|
51
52
|
*/
|
|
52
53
|
const SINGLE_WORD_SPECIALS = new Set(['#', '?', '$', '!', '-']);
|
|
53
|
-
/**
|
|
54
|
-
* Bash variables that always expand to a decimal integer, so an unquoted
|
|
55
|
-
* reference is one word with no glob characters, like the specials above.
|
|
56
|
-
* Bash keeps them integers: assigning text to `RANDOM`, `SECONDS` or `OPTIND`
|
|
57
|
-
* evaluates it arithmetically, `LINENO` and `BASH_SUBSHELL` ignore
|
|
58
|
-
* assignments, and `PPID` is read-only; a hostile value in the environment is
|
|
59
|
-
* ignored for each of them. Each exists in every Bash from 3.2, the oldest
|
|
60
|
-
* target eslint-sh supports.
|
|
61
|
-
*
|
|
62
|
-
* Left out on purpose: `UID` and `EUID`, which Bash imports verbatim from the
|
|
63
|
-
* environment (`env UID='a *' bash -c 'echo "[$UID]"'` prints `[a *]`), so
|
|
64
|
-
* they can hold text that splits and globs; `BASHPID` (Bash 4.0), `EPOCHSECONDS` (5.0) and
|
|
65
|
-
* `SRANDOM` (5.1), which an older Bash leaves unset, so an unquoted reference
|
|
66
|
-
* there expands to no word at all; `EPOCHREALTIME`, whose decimal separator
|
|
67
|
-
* follows the locale; and variables a script or the environment may set to
|
|
68
|
-
* any text (`COLUMNS`, `SHLVL`). The list does not apply outside Bash: in a
|
|
69
|
-
* POSIX shell such as dash these are ordinary variables.
|
|
70
|
-
*/
|
|
71
|
-
const BASH_INTEGER_VARIABLES = new Set([
|
|
72
|
-
'RANDOM',
|
|
73
|
-
'SECONDS',
|
|
74
|
-
'LINENO',
|
|
75
|
-
'PPID',
|
|
76
|
-
'OPTIND',
|
|
77
|
-
'BASH_SUBSHELL',
|
|
78
|
-
]);
|
|
79
|
-
/** The dialects whose scripts run under Bash, where {@link BASH_INTEGER_VARIABLES} holds. */
|
|
54
|
+
/** The dialects whose scripts run under Bash, where {@link isBashIntegerVariable} holds. */
|
|
80
55
|
const BASH_DIALECTS = new Set(['bash', 'bats']);
|
|
81
56
|
/** Redirection operators that introduce a here-document (`<<`, `<<-`). */
|
|
82
57
|
const HEREDOC_OPERATORS = new Set([Hdoc, DashHdoc]);
|
|
@@ -94,7 +69,7 @@ function isSingleWordParamExp(node, bashIntegers) {
|
|
|
94
69
|
}
|
|
95
70
|
const name = node.param?.value;
|
|
96
71
|
return (name !== undefined &&
|
|
97
|
-
(SINGLE_WORD_SPECIALS.has(name) || (bashIntegers &&
|
|
72
|
+
(SINGLE_WORD_SPECIALS.has(name) || (bashIntegers && isBashIntegerVariable(name))) &&
|
|
98
73
|
!node.excl &&
|
|
99
74
|
node.exp === undefined &&
|
|
100
75
|
node.repl === undefined &&
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `sh/require-network-wrapper`: every network command must be run through the
|
|
3
|
-
* project's configured wrapper (a retry, audit, or proxy helper).
|
|
4
|
-
*
|
|
5
|
-
* This is the built-in instance of `createWrapperRule`: the commands
|
|
6
|
-
* default to common network clients, and the wrapper comes from the user's
|
|
7
|
-
* rule's `wrapper` option, or else from the shared `settings.sh.networkWrapper`
|
|
8
|
-
* (which the declaration rules also read). The wrapper has **no default**.
|
|
9
|
-
* Which wrapper guards network calls is a project's policy, and a generic
|
|
10
|
-
* linter must not invent it, so with neither set the rule is inert: it
|
|
11
|
-
* registers no listeners, reports nothing, and never analyzes the file.
|
|
12
|
-
*
|
|
13
|
-
* `git` counts only for the subcommands that talk to a remote (`fetch`,
|
|
14
|
-
* `clone`, `pull`, `push`), found after git's own global options
|
|
15
|
-
* (`git -C dir fetch`).
|
|
16
|
-
*/
|
|
17
1
|
import type { WrapperSpec } from 'sh-ast/analyze';
|
|
18
2
|
/**
|
|
19
3
|
* The options of `sh/require-network-wrapper`.
|
|
@@ -26,7 +10,8 @@ export interface RequireNetworkWrapperOptions {
|
|
|
26
10
|
/**
|
|
27
11
|
* The wrapper network commands must be run through: a name, or a
|
|
28
12
|
* `WrapperSpec` declaring the wrapper's option shape. Unset falls back to
|
|
29
|
-
* `settings
|
|
13
|
+
* `settings: { sh: { networkWrapper: 'with-retry' } }`; conflicting values
|
|
14
|
+
* throw instead of overriding the shared policy. With neither, the rule is inert.
|
|
30
15
|
*/
|
|
31
16
|
readonly wrapper?: string | WrapperSpec;
|
|
32
17
|
/** Commands checked in addition to `commands` (or its default). */
|
|
@@ -1,6 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sh/require-network-wrapper`: every network command must be run through the
|
|
3
|
+
* project's configured wrapper (a retry, audit, or proxy helper).
|
|
4
|
+
*
|
|
5
|
+
* This is the built-in instance of `createWrapperRule`: the commands
|
|
6
|
+
* default to common network clients, and the wrapper comes from the user's
|
|
7
|
+
* shared `settings: { sh: { networkWrapper: 'with-retry' } }` or the local
|
|
8
|
+
* `wrapper` option. Conflicting values throw, so policies cannot silently disagree. The wrapper has **no default**.
|
|
9
|
+
* Which wrapper guards network calls is a project's policy, and a generic
|
|
10
|
+
* linter must not invent it, so with neither set the rule is inert: it
|
|
11
|
+
* registers no listeners, reports nothing, and never analyzes the file.
|
|
12
|
+
*
|
|
13
|
+
* `git` counts only for the subcommands that talk to a remote (`fetch`,
|
|
14
|
+
* `clone`, `pull`, `push`), found after git's own global options
|
|
15
|
+
* (`git -C dir fetch`).
|
|
16
|
+
*/
|
|
17
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
1
18
|
import { createRule } from './create-rule.js';
|
|
2
19
|
import { WRAPPER_RULE_MESSAGES, buildWrapperRule } from './create-wrapper-rule.js';
|
|
3
|
-
import { readNetworkWrapperSetting } from './resolve-network-wrapper.js';
|
|
20
|
+
import { networkWrapperSpec, readNetworkWrapperSetting, sameWrapperSpec, } from './resolve-network-wrapper.js';
|
|
4
21
|
/** The registry name of this rule (`sh/require-network-wrapper` in a user's config). */
|
|
5
22
|
const RULE_NAME = 'require-network-wrapper';
|
|
6
23
|
/**
|
|
@@ -182,6 +199,7 @@ const WRAPPER_SPEC_SCHEMA = {
|
|
|
182
199
|
stopsChainFlags: STRING_LIST_SCHEMA,
|
|
183
200
|
positionalOperandsBeforeCommand: { type: 'integer', minimum: 0 },
|
|
184
201
|
assignmentOperands: { enum: ['shell-identifier', 'env', 'sudo'] },
|
|
202
|
+
shellBuiltin: { type: 'boolean' },
|
|
185
203
|
},
|
|
186
204
|
required: ['names'],
|
|
187
205
|
additionalProperties: false,
|
|
@@ -191,9 +209,20 @@ const WRAPPER_SPEC_SCHEMA = {
|
|
|
191
209
|
* `null` when no wrapper is configured.
|
|
192
210
|
*/
|
|
193
211
|
function wrapperRuleConfig(options, settings) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
const shared = readNetworkWrapperSetting(settings);
|
|
213
|
+
if (options?.wrapper !== undefined && shared !== undefined) {
|
|
214
|
+
const configured = options.wrapper;
|
|
215
|
+
const option = typeof configured === 'string'
|
|
216
|
+
? (DEFAULT_TRANSPARENT_WRAPPERS.find((spec) => spec.names.includes(configured)) ?? {
|
|
217
|
+
names: [configured],
|
|
218
|
+
})
|
|
219
|
+
: configured;
|
|
220
|
+
const network = networkWrapperSpec(settings);
|
|
221
|
+
if (network !== null && !sameWrapperSpec(option, network)) {
|
|
222
|
+
throw new Error('sh/require-network-wrapper: `wrapper` conflicts with the shared networkWrapper; configure one shared network policy.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const wrapper = shared ?? options?.wrapper;
|
|
197
226
|
if (wrapper === undefined)
|
|
198
227
|
return null;
|
|
199
228
|
return {
|
|
@@ -52,16 +52,15 @@ export interface DeclarationConventionBindingContext {
|
|
|
52
52
|
/**
|
|
53
53
|
* Resolves the declaration convention bound for the rule running in
|
|
54
54
|
* `context`: the rule's `declarationConvention` option, else
|
|
55
|
-
* `settings
|
|
55
|
+
* `settings: { sh: { declarationConvention: convention } }`, else `null`
|
|
56
|
+
* (the rule is inert).
|
|
56
57
|
*
|
|
57
58
|
* Throws an `Error` naming the offending setting when the configured value is
|
|
58
59
|
* present but does not implement `declaredCommands` (or has a non-function
|
|
59
60
|
* `removeDeclaration`). The message names where the value came from and what
|
|
60
|
-
* it was,
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
* surfaces as ESLint's
|
|
64
|
-
* configuration message for the file being linted.
|
|
61
|
+
* it was, including the expected convention shape and the received value.
|
|
62
|
+
* Call it from `create()`, so the error surfaces as ESLint's configuration
|
|
63
|
+
* message for the file being linted.
|
|
65
64
|
*
|
|
66
65
|
* A rule that receives `null` should register no listeners: with no
|
|
67
66
|
* convention bound, a convention consumer has nothing to check.
|
|
@@ -51,16 +51,15 @@ function assertDeclarationConvention(value, setting) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Resolves the declaration convention bound for the rule running in
|
|
53
53
|
* `context`: the rule's `declarationConvention` option, else
|
|
54
|
-
* `settings
|
|
54
|
+
* `settings: { sh: { declarationConvention: convention } }`, else `null`
|
|
55
|
+
* (the rule is inert).
|
|
55
56
|
*
|
|
56
57
|
* Throws an `Error` naming the offending setting when the configured value is
|
|
57
58
|
* present but does not implement `declaredCommands` (or has a non-function
|
|
58
59
|
* `removeDeclaration`). The message names where the value came from and what
|
|
59
|
-
* it was,
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
62
|
-
* surfaces as ESLint's
|
|
63
|
-
* configuration message for the file being linted.
|
|
60
|
+
* it was, including the expected convention shape and the received value.
|
|
61
|
+
* Call it from `create()`, so the error surfaces as ESLint's configuration
|
|
62
|
+
* message for the file being linted.
|
|
64
63
|
*
|
|
65
64
|
* A rule that receives `null` should register no listeners: with no
|
|
66
65
|
* convention bound, a convention consumer has nothing to check.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { WrapperSpec } from 'sh-ast/analyze';
|
|
2
|
+
/**
|
|
3
|
+
* Checks a configured value and returns it typed: a non-empty command name,
|
|
4
|
+
* or a `WrapperSpec` object made of the JSON-expressible fields.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export declare function validateWrapperSetting(value: unknown, setting: string): string | WrapperSpec;
|
|
2
8
|
/**
|
|
3
9
|
* Reads `settings.sh.networkWrapper`: the configured name or `WrapperSpec`,
|
|
4
10
|
* or `undefined` when it is not set. Throws an error that starts
|
|
@@ -17,3 +23,16 @@ export declare function readNetworkWrapperSetting(settings: Readonly<Record<stri
|
|
|
17
23
|
* @internal
|
|
18
24
|
*/
|
|
19
25
|
export declare function networkWrapperSpec(settings: Readonly<Record<string, unknown>>): WrapperSpec | null;
|
|
26
|
+
/**
|
|
27
|
+
* Whether overlapping wrapper names receive the same effective matcher grammar.
|
|
28
|
+
* Extra aliases are independent facts, not a conflict for the shared names.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export declare function sameWrapperOptions(left: WrapperSpec, right: WrapperSpec): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether both specs approve the same names with the same effective grammar.
|
|
34
|
+
* Network policy compares the full name set: a transparent alias alone must
|
|
35
|
+
* not become an approved network wrapper. Name and flag order do not matter.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export declare function sameWrapperSpec(left: WrapperSpec, right: WrapperSpec): boolean;
|
|
@@ -1,24 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* whose findings depend on that wrapper reads the same declaration.
|
|
5
|
-
*
|
|
6
|
-
* Two different statements are involved, and this setting makes only one of
|
|
7
|
-
* them:
|
|
8
|
-
*
|
|
9
|
-
* - **Policy:** network commands must run through this wrapper. That is what
|
|
10
|
-
* `sh/require-network-wrapper` enforces, and this setting states it.
|
|
11
|
-
* - **Fact:** this wrapper runs the command named after it. The policy implies
|
|
12
|
-
* the fact (a wrapper that encloses `curl` runs `curl`), so
|
|
13
|
-
* `sh/no-undeclared-commands` and `sh/no-unused-declarations` look through
|
|
14
|
-
* the network wrapper when it is set here. The converse does not hold: a
|
|
15
|
-
* transparent wrapper such as `with-lock` is not the network wrapper, and
|
|
16
|
-
* stays in those rules' `transparentWrappers` option.
|
|
17
|
-
*
|
|
18
|
-
* ESLint gives a rule no way to read another rule's options, so a wrapper set
|
|
19
|
-
* only as `require-network-wrapper`'s `wrapper` option is invisible to the
|
|
20
|
-
* declaration rules. Shared settings are visible to every rule; that is why
|
|
21
|
-
* the one declaration lives here.
|
|
2
|
+
* Reads the shared network policy and validates JSON-expressible wrapper specs.
|
|
3
|
+
* A network wrapper also supplies a transparent-wrapper fact to every consumer.
|
|
22
4
|
*/
|
|
23
5
|
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
24
6
|
/** The setting's dotted path, as error messages name it. */
|
|
@@ -34,21 +16,23 @@ const KNOWN_FIELDS = new Set([
|
|
|
34
16
|
...STRING_LIST_FIELDS,
|
|
35
17
|
'positionalOperandsBeforeCommand',
|
|
36
18
|
'assignmentOperands',
|
|
19
|
+
'shellBuiltin',
|
|
37
20
|
]);
|
|
38
21
|
/** `true` when `value` is an array of non-empty strings. */
|
|
39
22
|
function isStringList(value) {
|
|
40
23
|
return (Array.isArray(value) &&
|
|
41
24
|
value.every((item) => typeof item === 'string' && item.length > 0));
|
|
42
25
|
}
|
|
43
|
-
/** Throws the setting's configuration error with `detail`. */
|
|
44
|
-
function invalid(detail) {
|
|
45
|
-
throw new Error(`Invalid ${SETTING}: ${detail}`);
|
|
46
|
-
}
|
|
47
26
|
/**
|
|
48
27
|
* Checks a configured value and returns it typed: a non-empty command name,
|
|
49
28
|
* or a `WrapperSpec` object made of the JSON-expressible fields.
|
|
29
|
+
* @internal
|
|
50
30
|
*/
|
|
51
|
-
function
|
|
31
|
+
export function validateWrapperSetting(value, setting) {
|
|
32
|
+
/** Names the configuration binding instead of exposing an analysis failure. */
|
|
33
|
+
function invalid(detail) {
|
|
34
|
+
throw new Error(`Invalid ${setting}: ${detail}`);
|
|
35
|
+
}
|
|
52
36
|
if (typeof value === 'string') {
|
|
53
37
|
if (value.length === 0)
|
|
54
38
|
invalid('expected a command name or a wrapper spec, but got "".');
|
|
@@ -71,6 +55,9 @@ function validate(value) {
|
|
|
71
55
|
invalid(`\`${field}\` must be an array of non-empty strings.`);
|
|
72
56
|
}
|
|
73
57
|
}
|
|
58
|
+
if (spec.shellBuiltin !== undefined && typeof spec.shellBuiltin !== 'boolean') {
|
|
59
|
+
invalid('`shellBuiltin` must be a boolean.');
|
|
60
|
+
}
|
|
74
61
|
const positional = spec.positionalOperandsBeforeCommand;
|
|
75
62
|
if (positional !== undefined && !(Number.isInteger(positional) && positional >= 0)) {
|
|
76
63
|
invalid('`positionalOperandsBeforeCommand` must be a non-negative integer.');
|
|
@@ -94,7 +81,7 @@ export function readNetworkWrapperSetting(settings) {
|
|
|
94
81
|
if (typeof sh !== 'object' || sh === null)
|
|
95
82
|
return undefined;
|
|
96
83
|
const value = sh.networkWrapper;
|
|
97
|
-
return value === undefined ? undefined :
|
|
84
|
+
return value === undefined ? undefined : validateWrapperSetting(value, SETTING);
|
|
98
85
|
}
|
|
99
86
|
/**
|
|
100
87
|
* The `WrapperSpec` for the network wrapper set in `settings.sh.networkWrapper`,
|
|
@@ -114,3 +101,47 @@ export function networkWrapperSpec(settings) {
|
|
|
114
101
|
names: [wrapper],
|
|
115
102
|
});
|
|
116
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Canonical effective grammar from sh-ast 0.9.0's WrapperSpec matcher, excluding
|
|
106
|
+
* the names that select it. Flags are membership sets; omitted lists are empty,
|
|
107
|
+
* shellBuiltin defaults to false (utility matching), and positional count
|
|
108
|
+
* defaults to zero. An explicit assignment grammar supersedes the legacy
|
|
109
|
+
* shell-identifier alias; otherwise there is no assignment grammar.
|
|
110
|
+
*
|
|
111
|
+
* Expanded built-ins may contain a flag pattern even though JSON settings may
|
|
112
|
+
* not. Compare its source and flags structurally, including absence, rather
|
|
113
|
+
* than attempting regex-language equivalence or inheriting omitted fields.
|
|
114
|
+
*/
|
|
115
|
+
function canonicalWrapperGrammar(spec) {
|
|
116
|
+
/** Stable membership representation for the matcher's four flag lists. */
|
|
117
|
+
const flags = (values) => [...new Set(values ?? [])].sort();
|
|
118
|
+
const pattern = spec.noArgFlagPattern;
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
noArgFlags: flags(spec.noArgFlags),
|
|
121
|
+
argFlags: flags(spec.argFlags),
|
|
122
|
+
unresolvableFlags: flags(spec.unresolvableFlags),
|
|
123
|
+
stopsChainFlags: flags(spec.stopsChainFlags),
|
|
124
|
+
shellBuiltin: spec.shellBuiltin ?? false,
|
|
125
|
+
positionalOperandsBeforeCommand: spec.positionalOperandsBeforeCommand ?? 0,
|
|
126
|
+
assignmentOperands: spec.assignmentOperands ?? (spec.skipAssignmentOperands === true ? 'shell-identifier' : null),
|
|
127
|
+
noArgFlagPattern: pattern === undefined ? null : { source: pattern.source, flags: pattern.flags },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether overlapping wrapper names receive the same effective matcher grammar.
|
|
132
|
+
* Extra aliases are independent facts, not a conflict for the shared names.
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export function sameWrapperOptions(left, right) {
|
|
136
|
+
return canonicalWrapperGrammar(left) === canonicalWrapperGrammar(right);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether both specs approve the same names with the same effective grammar.
|
|
140
|
+
* Network policy compares the full name set: a transparent alias alone must
|
|
141
|
+
* not become an approved network wrapper. Name and flag order do not matter.
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
export function sameWrapperSpec(left, right) {
|
|
145
|
+
return (JSON.stringify([...left.names].sort()) === JSON.stringify([...right.names].sort()) &&
|
|
146
|
+
sameWrapperOptions(left, right));
|
|
147
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves project wrapper facts once per consuming rule. Shared facts precede
|
|
3
|
+
* the rule's built-in table, so an explicit option shape replaces the default
|
|
4
|
+
* for that name. Rules retain their distinct built-in scopes, but never their
|
|
5
|
+
* own interpretation of a configured project wrapper.
|
|
6
|
+
*/
|
|
7
|
+
import type { SettingsConfig } from '@eslint/core';
|
|
8
|
+
import type { WrapperSpec } from 'sh-ast/analyze';
|
|
9
|
+
import type { ShellSourceCode } from '../language/shell-source-code.js';
|
|
10
|
+
/** The rule-context fields needed to resolve shared wrappers and their path spellings. @public */
|
|
11
|
+
export interface TransparentWrappersBindingContext {
|
|
12
|
+
/** Shared ESLint configuration containing the project's shell facts. */
|
|
13
|
+
readonly settings: SettingsConfig;
|
|
14
|
+
/** The current file supplies path spellings without filesystem inspection or execution. */
|
|
15
|
+
readonly sourceCode: ShellSourceCode;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns the shared project wrappers followed by `builtIns` (defaults when
|
|
19
|
+
* omitted). Entries accept command names or JSON-expressible `WrapperSpec`s
|
|
20
|
+
* in `settings: { sh: { transparentWrappers: [...] } }`. The shared network
|
|
21
|
+
* wrapper is also transparent; conflicting specs for the same project name
|
|
22
|
+
* throw rather than allowing policies to disagree. Shared specs compare effective
|
|
23
|
+
* grammars for overlapping names, normalizing matcher defaults and retaining
|
|
24
|
+
* expanded built-in flag patterns; an extra transparent alias does not acquire
|
|
25
|
+
* network approval. Explicit specs remain complete replacements. Duplicate project
|
|
26
|
+
* names are rejected so list order cannot silently choose their meaning.
|
|
27
|
+
*
|
|
28
|
+
* Utility wrappers match absolute and relative paths by basename. Path words
|
|
29
|
+
* remain unchanged in analysis results. `shellBuiltin: true` entries match
|
|
30
|
+
* exact names only: an executable path cannot acquire in-process semantics.
|
|
31
|
+
* An explicit path spec takes precedence over a basename fallback regardless
|
|
32
|
+
* of configuration order.
|
|
33
|
+
* Aliases are built even when only `builtIns` are supplied. Overridden names
|
|
34
|
+
* are removed from lower-priority entries, preserving their unrelated aliases
|
|
35
|
+
* and preventing a utility entry from undoing an exact-name builtin override.
|
|
36
|
+
* The input tables are never mutated.
|
|
37
|
+
*
|
|
38
|
+
* @param context - The consuming rule's context.
|
|
39
|
+
* @param builtIns - The rule's own built-in wrapper scope.
|
|
40
|
+
* @throws Error when a configured wrapper is malformed or contradictory.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveTransparentWrappers(context: TransparentWrappersBindingContext, builtIns?: readonly WrapperSpec[]): readonly WrapperSpec[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
2
|
+
import { networkWrapperSpec, sameWrapperOptions, validateWrapperSetting, } from './resolve-network-wrapper.js';
|
|
3
|
+
/** The complete nested binding, used in configuration errors. */
|
|
4
|
+
const SETTING = 'settings: { sh: { transparentWrappers: [...] } }';
|
|
5
|
+
/** A name-only entry preserves a known utility's default flag handling. */
|
|
6
|
+
function asSpec(value) {
|
|
7
|
+
if (typeof value !== 'string') {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
return (DEFAULT_TRANSPARENT_WRAPPERS.find((spec) => spec.names.includes(value)) ?? { names: [value] });
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns the shared project wrappers followed by `builtIns` (defaults when
|
|
14
|
+
* omitted). Entries accept command names or JSON-expressible `WrapperSpec`s
|
|
15
|
+
* in `settings: { sh: { transparentWrappers: [...] } }`. The shared network
|
|
16
|
+
* wrapper is also transparent; conflicting specs for the same project name
|
|
17
|
+
* throw rather than allowing policies to disagree. Shared specs compare effective
|
|
18
|
+
* grammars for overlapping names, normalizing matcher defaults and retaining
|
|
19
|
+
* expanded built-in flag patterns; an extra transparent alias does not acquire
|
|
20
|
+
* network approval. Explicit specs remain complete replacements. Duplicate project
|
|
21
|
+
* names are rejected so list order cannot silently choose their meaning.
|
|
22
|
+
*
|
|
23
|
+
* Utility wrappers match absolute and relative paths by basename. Path words
|
|
24
|
+
* remain unchanged in analysis results. `shellBuiltin: true` entries match
|
|
25
|
+
* exact names only: an executable path cannot acquire in-process semantics.
|
|
26
|
+
* An explicit path spec takes precedence over a basename fallback regardless
|
|
27
|
+
* of configuration order.
|
|
28
|
+
* Aliases are built even when only `builtIns` are supplied. Overridden names
|
|
29
|
+
* are removed from lower-priority entries, preserving their unrelated aliases
|
|
30
|
+
* and preventing a utility entry from undoing an exact-name builtin override.
|
|
31
|
+
* The input tables are never mutated.
|
|
32
|
+
*
|
|
33
|
+
* @param context - The consuming rule's context.
|
|
34
|
+
* @param builtIns - The rule's own built-in wrapper scope.
|
|
35
|
+
* @throws Error when a configured wrapper is malformed or contradictory.
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export function resolveTransparentWrappers(context, builtIns = DEFAULT_TRANSPARENT_WRAPPERS) {
|
|
39
|
+
const sh = context.settings.sh;
|
|
40
|
+
const value = typeof sh === 'object' && sh !== null
|
|
41
|
+
? sh.transparentWrappers
|
|
42
|
+
: undefined;
|
|
43
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
44
|
+
throw new Error(`Invalid ${SETTING}: expected an array of command names or wrapper specs.`);
|
|
45
|
+
}
|
|
46
|
+
const configured = (value ?? []).map((entry) => asSpec(validateWrapperSetting(entry, SETTING)));
|
|
47
|
+
const names = new Set();
|
|
48
|
+
for (const spec of configured) {
|
|
49
|
+
for (const name of spec.names) {
|
|
50
|
+
if (names.has(name)) {
|
|
51
|
+
throw new Error(`Invalid ${SETTING}: duplicate wrapper name ${JSON.stringify(name)}.`);
|
|
52
|
+
}
|
|
53
|
+
names.add(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const network = networkWrapperSpec(context.settings);
|
|
57
|
+
for (const spec of configured) {
|
|
58
|
+
if (network !== null &&
|
|
59
|
+
spec.names.some((name) => network.names.includes(name)) &&
|
|
60
|
+
!sameWrapperOptions(spec, network)) {
|
|
61
|
+
throw new Error(`Invalid ${SETTING}: a wrapper spec conflicts with the shared networkWrapper.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Precedence applies to names, including their path-matching semantics.
|
|
65
|
+
// Keeping a shadowed utility name could recreate an alias that a higher
|
|
66
|
+
// priority shellBuiltin entry explicitly excludes.
|
|
67
|
+
const table = [];
|
|
68
|
+
const claimed = new Set();
|
|
69
|
+
for (const spec of [...configured, ...(network === null ? [] : [network]), ...builtIns]) {
|
|
70
|
+
const remaining = spec.names.filter((name) => !claimed.has(name));
|
|
71
|
+
if (remaining.length === 0) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
table.push(remaining.length === spec.names.length ? spec : { ...spec, names: remaining });
|
|
75
|
+
for (const name of remaining) {
|
|
76
|
+
claimed.add(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Relative utility paths need exact aliases in sh-ast. Derive them only from
|
|
80
|
+
// static argv words in this file; no path is resolved or executed here.
|
|
81
|
+
const paths = new Map();
|
|
82
|
+
for (const site of context.sourceCode.getCommandSites()) {
|
|
83
|
+
for (const word of site.argv) {
|
|
84
|
+
if (!word.static || !word.text.includes('/') || paths.has(word.text)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const base = word.text.slice(word.text.lastIndexOf('/') + 1);
|
|
88
|
+
const spec = table.find((entry) => entry.names.includes(word.text)) ??
|
|
89
|
+
table.find((entry) => entry.shellBuiltin !== true && entry.names.includes(base));
|
|
90
|
+
// Keep the selected exact spelling ahead of generic entries; deriving
|
|
91
|
+
// an alias from an earlier basename would erase an explicit path's flags.
|
|
92
|
+
if (spec !== undefined) {
|
|
93
|
+
paths.set(word.text, { ...spec, names: [word.text] });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...paths.values(), ...table];
|
|
98
|
+
}
|
|
@@ -99,7 +99,8 @@ export declare function formatSetCommand(options: readonly string[]): string;
|
|
|
99
99
|
* (`#!/bin/bash -eu`); more than one word (`#!/bin/bash -e -u`) arrives as a
|
|
100
100
|
* single argument the shell rejects, and counts as nothing.
|
|
101
101
|
* - `#!/usr/bin/env -S <shell> <words…>`: `env -S` splits the rest into words,
|
|
102
|
-
* so every word after the shell name is read.
|
|
102
|
+
* so every word after the shell name is read. GNU env's
|
|
103
|
+
* `--split-string=<shell> <words…>` is read the same way; macOS env supports only `-S`.
|
|
103
104
|
*
|
|
104
105
|
* Plain `#!/usr/bin/env <shell> <flags>` counts as nothing: without `-S`,
|
|
105
106
|
* Linux passes `<shell> <flags>` to `env` as one command name.
|
|
@@ -168,7 +168,8 @@ export function formatSetCommand(options) {
|
|
|
168
168
|
* (`#!/bin/bash -eu`); more than one word (`#!/bin/bash -e -u`) arrives as a
|
|
169
169
|
* single argument the shell rejects, and counts as nothing.
|
|
170
170
|
* - `#!/usr/bin/env -S <shell> <words…>`: `env -S` splits the rest into words,
|
|
171
|
-
* so every word after the shell name is read.
|
|
171
|
+
* so every word after the shell name is read. GNU env's
|
|
172
|
+
* `--split-string=<shell> <words…>` is read the same way; macOS env supports only `-S`.
|
|
172
173
|
*
|
|
173
174
|
* Plain `#!/usr/bin/env <shell> <flags>` counts as nothing: without `-S`,
|
|
174
175
|
* Linux passes `<shell> <flags>` to `env` as one command name.
|
|
@@ -184,13 +185,24 @@ export function shebangOptionChanges(text) {
|
|
|
184
185
|
.trim()
|
|
185
186
|
.split(/\s+/u);
|
|
186
187
|
const [interpreter = '', ...rest] = words;
|
|
187
|
-
|
|
188
|
+
// Relative interpreters are resolved against the caller's directory, never through PATH.
|
|
189
|
+
if (!interpreter.startsWith('/'))
|
|
190
|
+
return [];
|
|
191
|
+
const isEnv = interpreter.endsWith('/env');
|
|
188
192
|
let flagWords;
|
|
189
193
|
if (isEnv) {
|
|
190
|
-
const [split, , ...shellArgs] = rest;
|
|
191
|
-
|
|
194
|
+
const [split = '', , ...shellArgs] = rest;
|
|
195
|
+
// The kernel's single argument makes `--split-string bash -eu` an unknown long option.
|
|
196
|
+
if (split === '-S') {
|
|
197
|
+
flagWords = shellArgs;
|
|
198
|
+
}
|
|
199
|
+
else if (split.startsWith('--split-string=')) {
|
|
200
|
+
// The first word carries the command when attached; an empty value puts it next.
|
|
201
|
+
flagWords = rest.slice(split === '--split-string=' ? 2 : 1);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
192
204
|
return [];
|
|
193
|
-
|
|
205
|
+
}
|
|
194
206
|
}
|
|
195
207
|
else {
|
|
196
208
|
if (rest.length !== 1)
|
|
@@ -34,9 +34,11 @@ function envCommand(words) {
|
|
|
34
34
|
if (word === '--') {
|
|
35
35
|
return words.slice(index + 1).find((operand) => !ENV_ASSIGNMENT.test(operand));
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
// GNU env sees the whole kernel argument as an option name unless the long form uses '='.
|
|
38
|
+
if (word === '--split-string')
|
|
39
|
+
return undefined;
|
|
40
|
+
if (word === '-S')
|
|
38
41
|
continue;
|
|
39
|
-
}
|
|
40
42
|
if (word.startsWith('--split-string=')) {
|
|
41
43
|
return word.slice('--split-string='.length) || words[index + 1];
|
|
42
44
|
}
|