@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.
Files changed (141) hide show
  1. package/README.md +14 -8
  2. package/dist/command-harvest/builtins/bash.d.ts +1 -1
  3. package/dist/command-harvest/builtins/bash.js +1 -1
  4. package/dist/command-harvest/builtins/bats.d.ts +2 -0
  5. package/dist/command-harvest/builtins/bats.js +3 -0
  6. package/dist/command-harvest/wrapper-table.d.ts +3 -19
  7. package/dist/command-harvest/wrapper-table.js +8 -38
  8. package/dist/configs/bash-3-2.d.ts +7 -6
  9. package/dist/configs/bash-3-2.js +4 -3
  10. package/dist/configs/posix-portable.d.ts +1 -1
  11. package/dist/declaration-conventions/read-declared-commands.d.ts +2 -2
  12. package/dist/declaration-conventions/read-declared-commands.js +2 -2
  13. package/dist/eslint-plugin-syntax.d.ts +4 -0
  14. package/dist/eslint-plugin.d.ts +86 -40
  15. package/dist/index.d.ts +5 -2
  16. package/dist/index.js +4 -2
  17. package/dist/language/shell-language-options.d.ts +18 -15
  18. package/dist/language/shell-language-options.js +33 -26
  19. package/dist/language/shell-language.d.ts +10 -9
  20. package/dist/language/shell-language.js +18 -35
  21. package/dist/language/shell-source-code.d.ts +17 -9
  22. package/dist/language/shell-source-code.js +53 -12
  23. package/dist/rules/bash-variable-semantics.d.ts +22 -0
  24. package/dist/rules/bash-variable-semantics.js +41 -0
  25. package/dist/rules/compat/create-compat-rule.js +1 -1
  26. package/dist/rules/compat/declaration-options.d.ts +2 -2
  27. package/dist/rules/compat/declaration-options.js +5 -2
  28. package/dist/rules/compat/no-append-all-redirect.js +1 -1
  29. package/dist/rules/compat/no-associative-arrays.js +2 -2
  30. package/dist/rules/compat/no-brace-sequence-padding-step.js +4 -4
  31. package/dist/rules/compat/no-case-attributes.js +3 -3
  32. package/dist/rules/compat/no-case-fallthrough.js +2 -2
  33. package/dist/rules/compat/no-case-modification.js +2 -2
  34. package/dist/rules/compat/no-coproc.js +1 -1
  35. package/dist/rules/compat/no-declare-g.js +3 -3
  36. package/dist/rules/compat/no-double-bracket.js +2 -2
  37. package/dist/rules/compat/no-epoch-variables.js +2 -2
  38. package/dist/rules/compat/no-fd-variable.js +1 -1
  39. package/dist/rules/compat/no-globstar.js +1 -1
  40. package/dist/rules/compat/no-lastpipe-semantics.js +2 -2
  41. package/dist/rules/compat/no-let.js +2 -1
  42. package/dist/rules/compat/no-mapfile.js +4 -2
  43. package/dist/rules/compat/no-nameref.js +3 -3
  44. package/dist/rules/compat/no-negative-array-index.js +39 -9
  45. package/dist/rules/compat/no-nounset-empty-array.js +15 -4
  46. package/dist/rules/compat/no-parameter-transformation.js +2 -2
  47. package/dist/rules/compat/no-pipe-all.js +1 -1
  48. package/dist/rules/compat/no-printf-time-format.js +4 -2
  49. package/dist/rules/compat/no-source.js +2 -1
  50. package/dist/rules/compat/no-test-v.js +4 -2
  51. package/dist/rules/compat/no-unicode-escape.js +2 -2
  52. package/dist/rules/compat/no-wait-n.js +4 -2
  53. package/dist/rules/compat/nounset-array-proof.d.ts +8 -0
  54. package/dist/rules/compat/nounset-array-proof.js +563 -0
  55. package/dist/rules/compat/posix-absent-command.d.ts +7 -8
  56. package/dist/rules/compat/posix-absent-command.js +17 -15
  57. package/dist/rules/compat/shopt-options.js +3 -1
  58. package/dist/rules/compat/version-gates.d.ts +9 -9
  59. package/dist/rules/compat/version-gates.js +13 -13
  60. package/dist/rules/create-wrapper-rule.js +12 -7
  61. package/dist/rules/no-dynamic-invocation.d.ts +0 -8
  62. package/dist/rules/no-dynamic-invocation.js +7 -15
  63. package/dist/rules/no-eval.js +3 -1
  64. package/dist/rules/no-masked-exit-status.js +15 -8
  65. package/dist/rules/no-secret-env-reads.d.ts +4 -3
  66. package/dist/rules/no-secret-env-reads.js +37 -14
  67. package/dist/rules/no-secret-flags.d.ts +2 -1
  68. package/dist/rules/no-secret-flags.js +7 -8
  69. package/dist/rules/no-unchecked-cd.js +3 -1
  70. package/dist/rules/no-undeclared-commands.d.ts +1 -15
  71. package/dist/rules/no-undeclared-commands.js +3 -15
  72. package/dist/rules/no-unused-declarations.d.ts +1 -18
  73. package/dist/rules/no-unused-declarations.js +9 -34
  74. package/dist/rules/prefer-local.js +4 -3
  75. package/dist/rules/quote-expansions.js +3 -28
  76. package/dist/rules/require-network-wrapper.d.ts +2 -17
  77. package/dist/rules/require-network-wrapper.js +33 -4
  78. package/dist/rules/resolve-declaration-convention.d.ts +5 -6
  79. package/dist/rules/resolve-declaration-convention.js +5 -6
  80. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  81. package/dist/rules/resolve-network-wrapper.js +57 -26
  82. package/dist/rules/resolve-transparent-wrappers.d.ts +43 -0
  83. package/dist/rules/resolve-transparent-wrappers.js +98 -0
  84. package/dist/rules/set-options.d.ts +2 -1
  85. package/dist/rules/set-options.js +17 -5
  86. package/dist/rules/shebang-interpreter.js +4 -2
  87. package/dist/rules/transparent-wrappers.d.ts +4 -3
  88. package/dist/rules/transparent-wrappers.js +5 -4
  89. package/dist/rules/user-pattern.d.ts +5 -3
  90. package/dist/rules/user-pattern.js +6 -4
  91. package/dist/syntax/index.d.ts +4 -0
  92. package/dist/syntax/index.js +4 -0
  93. package/docs/compat.md +76 -27
  94. package/docs/custom-rule-tutorial.md +9 -3
  95. package/docs/examples/no-listed-commands-rule.ts +2 -2
  96. package/docs/rules/README.md +36 -4
  97. package/docs/rules/_template.md +2 -2
  98. package/docs/rules/compat/no-append-all-redirect.md +12 -12
  99. package/docs/rules/compat/no-associative-arrays.md +14 -14
  100. package/docs/rules/compat/no-brace-sequence-padding-step.md +12 -12
  101. package/docs/rules/compat/no-case-attributes.md +13 -13
  102. package/docs/rules/compat/no-case-fallthrough.md +12 -12
  103. package/docs/rules/compat/no-case-modification.md +11 -11
  104. package/docs/rules/compat/no-coproc.md +13 -13
  105. package/docs/rules/compat/no-declare-g.md +13 -13
  106. package/docs/rules/compat/no-double-bracket.md +2 -2
  107. package/docs/rules/compat/no-epoch-variables.md +13 -13
  108. package/docs/rules/compat/no-fd-variable.md +12 -12
  109. package/docs/rules/compat/no-globstar.md +13 -13
  110. package/docs/rules/compat/no-lastpipe-semantics.md +13 -13
  111. package/docs/rules/compat/no-let.md +2 -2
  112. package/docs/rules/compat/no-mapfile.md +16 -16
  113. package/docs/rules/compat/no-nameref.md +13 -13
  114. package/docs/rules/compat/no-negative-array-index.md +14 -14
  115. package/docs/rules/compat/no-nounset-empty-array.md +72 -17
  116. package/docs/rules/compat/no-parameter-transformation.md +19 -19
  117. package/docs/rules/compat/no-pipe-all.md +13 -13
  118. package/docs/rules/compat/no-printf-time-format.md +13 -13
  119. package/docs/rules/compat/no-source.md +2 -2
  120. package/docs/rules/compat/no-test-v.md +12 -12
  121. package/docs/rules/compat/no-unicode-escape.md +13 -13
  122. package/docs/rules/compat/no-wait-n.md +13 -13
  123. package/docs/rules/no-dynamic-invocation.md +17 -12
  124. package/docs/rules/no-eval.md +6 -1
  125. package/docs/rules/no-masked-exit-status.md +4 -0
  126. package/docs/rules/no-secret-env-reads.md +34 -3
  127. package/docs/rules/no-secret-flags.md +19 -2
  128. package/docs/rules/no-unchecked-cd.md +9 -2
  129. package/docs/rules/no-undeclared-commands.md +16 -15
  130. package/docs/rules/no-unused-declarations.md +17 -20
  131. package/docs/rules/prefer-local.md +20 -2
  132. package/docs/rules/quote-expansions.md +8 -5
  133. package/docs/rules/require-network-wrapper.md +22 -17
  134. package/docs/rules/require-shebang.md +10 -4
  135. package/docs/rules/require-strict-mode.md +6 -0
  136. package/docs/shellcheck.md +9 -5
  137. package/docs/transparent-wrappers.md +97 -0
  138. package/docs/wrapper-rules.md +9 -0
  139. package/package.json +2 -2
  140. package/dist/language/end-of-input-marker.d.ts +0 -29
  141. package/dist/language/end-of-input-marker.js +0 -88
@@ -1,10 +1,9 @@
1
- import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
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 `table`, also following a shell or
75
- * `env` named by path: while the effective command is a path whose basename
76
- * names one of {@link PATH_FOLLOWED_WRAPPERS}, that path is added as a
77
- * wrapper with the same spec and the site is resolved again. This mirrors how
78
- * `createWrapperRule` follows path-named wrappers. Each round adds a new
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 = PATH_FOLLOWED_WRAPPERS.find((entry) => entry.names.includes(base));
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 = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
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 && BASH_INTEGER_VARIABLES.has(name))) &&
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.sh.networkWrapper`; with neither, the rule is inert.
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
- // The rule option wins; otherwise the shared setting, which the
195
- // declaration rules read too (see `./resolve-network-wrapper.ts`).
196
- const wrapper = options?.wrapper ?? readNetworkWrapperSetting(settings);
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.sh.declarationConvention`, else `null` (the rule is inert).
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, 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.
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.sh.declarationConvention`, else `null` (the rule is inert).
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, for example one beginning
60
- * `Invalid settings.sh.declarationConvention: expected a declaration convention`
61
- * and ending `but got string "jq".` Call it from `create()`, so the error
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
- * The shared `settings.sh.networkWrapper` binding: the one place a project
3
- * names the wrapper its network commands must run through, so every rule
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 validate(value) {
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 : validate(value);
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
- const isEnv = interpreter === 'env' || interpreter.endsWith('/env');
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
- if (split !== '-S' && split !== '--split-string')
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
- flagWords = shellArgs;
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
- if (word === '-S' || word === '--split-string') {
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
  }