@eslint-sh/eslint-plugin 0.6.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 (147) hide show
  1. package/README.md +19 -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 +5 -3
  10. package/dist/configs/posix-portable.d.ts +1 -1
  11. package/dist/configs/posix-portable.js +1 -0
  12. package/dist/declaration-conventions/read-declared-commands.d.ts +2 -2
  13. package/dist/declaration-conventions/read-declared-commands.js +2 -2
  14. package/dist/eslint-plugin-syntax.d.ts +4 -0
  15. package/dist/eslint-plugin.d.ts +86 -40
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.js +4 -2
  18. package/dist/language/shell-language-options.d.ts +18 -15
  19. package/dist/language/shell-language-options.js +33 -26
  20. package/dist/language/shell-language.d.ts +10 -9
  21. package/dist/language/shell-language.js +18 -35
  22. package/dist/language/shell-source-code.d.ts +17 -9
  23. package/dist/language/shell-source-code.js +53 -12
  24. package/dist/rules/bash-variable-semantics.d.ts +22 -0
  25. package/dist/rules/bash-variable-semantics.js +41 -0
  26. package/dist/rules/compat/create-compat-rule.js +1 -1
  27. package/dist/rules/compat/declaration-options.d.ts +2 -2
  28. package/dist/rules/compat/declaration-options.js +5 -2
  29. package/dist/rules/compat/no-append-all-redirect.js +1 -1
  30. package/dist/rules/compat/no-associative-arrays.js +2 -2
  31. package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
  32. package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
  33. package/dist/rules/compat/no-case-attributes.js +3 -3
  34. package/dist/rules/compat/no-case-fallthrough.js +2 -2
  35. package/dist/rules/compat/no-case-modification.js +2 -2
  36. package/dist/rules/compat/no-coproc.js +1 -1
  37. package/dist/rules/compat/no-declare-g.js +3 -3
  38. package/dist/rules/compat/no-double-bracket.js +2 -2
  39. package/dist/rules/compat/no-epoch-variables.js +2 -2
  40. package/dist/rules/compat/no-fd-variable.js +1 -1
  41. package/dist/rules/compat/no-globstar.js +1 -1
  42. package/dist/rules/compat/no-lastpipe-semantics.js +2 -2
  43. package/dist/rules/compat/no-let.js +2 -1
  44. package/dist/rules/compat/no-mapfile.js +4 -2
  45. package/dist/rules/compat/no-nameref.js +3 -3
  46. package/dist/rules/compat/no-negative-array-index.js +39 -9
  47. package/dist/rules/compat/no-nounset-empty-array.js +15 -4
  48. package/dist/rules/compat/no-parameter-transformation.js +2 -2
  49. package/dist/rules/compat/no-pipe-all.js +1 -1
  50. package/dist/rules/compat/no-printf-time-format.js +4 -2
  51. package/dist/rules/compat/no-source.js +2 -1
  52. package/dist/rules/compat/no-test-v.js +4 -2
  53. package/dist/rules/compat/no-unicode-escape.js +2 -2
  54. package/dist/rules/compat/no-wait-n.js +4 -2
  55. package/dist/rules/compat/nounset-array-proof.d.ts +8 -0
  56. package/dist/rules/compat/nounset-array-proof.js +563 -0
  57. package/dist/rules/compat/posix-absent-command.d.ts +7 -8
  58. package/dist/rules/compat/posix-absent-command.js +17 -15
  59. package/dist/rules/compat/shopt-options.js +3 -1
  60. package/dist/rules/compat/version-gates.d.ts +10 -10
  61. package/dist/rules/compat/version-gates.js +21 -13
  62. package/dist/rules/create-wrapper-rule.js +12 -7
  63. package/dist/rules/index.js +2 -0
  64. package/dist/rules/no-dynamic-invocation.d.ts +0 -8
  65. package/dist/rules/no-dynamic-invocation.js +7 -15
  66. package/dist/rules/no-eval.js +3 -1
  67. package/dist/rules/no-masked-exit-status.js +15 -8
  68. package/dist/rules/no-secret-env-reads.d.ts +4 -3
  69. package/dist/rules/no-secret-env-reads.js +37 -14
  70. package/dist/rules/no-secret-flags.d.ts +2 -1
  71. package/dist/rules/no-secret-flags.js +7 -8
  72. package/dist/rules/no-unchecked-cd.js +3 -1
  73. package/dist/rules/no-undeclared-commands.d.ts +1 -15
  74. package/dist/rules/no-undeclared-commands.js +3 -15
  75. package/dist/rules/no-unused-declarations.d.ts +1 -18
  76. package/dist/rules/no-unused-declarations.js +9 -34
  77. package/dist/rules/prefer-local.js +4 -3
  78. package/dist/rules/quote-expansions.js +3 -28
  79. package/dist/rules/require-network-wrapper.d.ts +2 -17
  80. package/dist/rules/require-network-wrapper.js +33 -4
  81. package/dist/rules/require-tty-guard.js +30 -1
  82. package/dist/rules/resolve-declaration-convention.d.ts +5 -6
  83. package/dist/rules/resolve-declaration-convention.js +5 -6
  84. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  85. package/dist/rules/resolve-network-wrapper.js +57 -26
  86. package/dist/rules/resolve-transparent-wrappers.d.ts +43 -0
  87. package/dist/rules/resolve-transparent-wrappers.js +98 -0
  88. package/dist/rules/set-options.d.ts +2 -1
  89. package/dist/rules/set-options.js +17 -5
  90. package/dist/rules/shebang-interpreter.js +4 -2
  91. package/dist/rules/transparent-wrappers.d.ts +4 -3
  92. package/dist/rules/transparent-wrappers.js +5 -4
  93. package/dist/rules/user-pattern.d.ts +5 -3
  94. package/dist/rules/user-pattern.js +6 -4
  95. package/dist/syntax/index.d.ts +4 -0
  96. package/dist/syntax/index.js +4 -0
  97. package/docs/compat.md +90 -34
  98. package/docs/custom-rule-tutorial.md +9 -3
  99. package/docs/examples/no-listed-commands-rule.ts +2 -2
  100. package/docs/rules/README.md +38 -5
  101. package/docs/rules/_template.md +2 -2
  102. package/docs/rules/compat/no-append-all-redirect.md +12 -12
  103. package/docs/rules/compat/no-associative-arrays.md +14 -14
  104. package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
  105. package/docs/rules/compat/no-case-attributes.md +13 -13
  106. package/docs/rules/compat/no-case-fallthrough.md +12 -12
  107. package/docs/rules/compat/no-case-modification.md +11 -11
  108. package/docs/rules/compat/no-coproc.md +13 -13
  109. package/docs/rules/compat/no-declare-g.md +13 -13
  110. package/docs/rules/compat/no-double-bracket.md +2 -2
  111. package/docs/rules/compat/no-epoch-variables.md +13 -13
  112. package/docs/rules/compat/no-fd-variable.md +12 -12
  113. package/docs/rules/compat/no-globstar.md +13 -13
  114. package/docs/rules/compat/no-lastpipe-semantics.md +13 -13
  115. package/docs/rules/compat/no-let.md +2 -2
  116. package/docs/rules/compat/no-mapfile.md +16 -16
  117. package/docs/rules/compat/no-nameref.md +13 -13
  118. package/docs/rules/compat/no-negative-array-index.md +14 -14
  119. package/docs/rules/compat/no-nounset-empty-array.md +72 -17
  120. package/docs/rules/compat/no-parameter-transformation.md +19 -19
  121. package/docs/rules/compat/no-pipe-all.md +13 -13
  122. package/docs/rules/compat/no-printf-time-format.md +13 -13
  123. package/docs/rules/compat/no-source.md +2 -2
  124. package/docs/rules/compat/no-test-v.md +12 -12
  125. package/docs/rules/compat/no-unicode-escape.md +13 -13
  126. package/docs/rules/compat/no-wait-n.md +13 -13
  127. package/docs/rules/no-dynamic-invocation.md +17 -12
  128. package/docs/rules/no-eval.md +6 -1
  129. package/docs/rules/no-masked-exit-status.md +4 -0
  130. package/docs/rules/no-secret-env-reads.md +34 -3
  131. package/docs/rules/no-secret-flags.md +19 -2
  132. package/docs/rules/no-unchecked-cd.md +9 -2
  133. package/docs/rules/no-undeclared-commands.md +16 -15
  134. package/docs/rules/no-unused-declarations.md +17 -20
  135. package/docs/rules/prefer-local.md +20 -2
  136. package/docs/rules/quote-expansions.md +8 -5
  137. package/docs/rules/require-network-wrapper.md +22 -17
  138. package/docs/rules/require-shebang.md +10 -4
  139. package/docs/rules/require-strict-mode.md +6 -0
  140. package/docs/rules/require-tty-guard.md +4 -1
  141. package/docs/shellcheck.md +10 -5
  142. package/docs/transparent-wrappers.md +97 -0
  143. package/docs/untrusted-code.md +440 -0
  144. package/docs/wrapper-rules.md +9 -0
  145. package/package.json +2 -2
  146. package/dist/language/end-of-input-marker.d.ts +0 -29
  147. package/dist/language/end-of-input-marker.js +0 -88
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * A gate is data: a feature name mapped to the oldest Bash release that has
6
6
  * it and whether POSIX sh has it. A new gate is a new row here, not new
7
- * decision logic in a rule. The semantics of `languageOptions.shellVersion`
7
+ * decision logic in a rule. The semantics of `languageOptions.minBashVersion`
8
8
  * and `languageOptions.dialect` for gating are defined once, by
9
9
  * {@link isFeatureAvailable}, and documented for users in `docs/compat.md`.
10
10
  *
@@ -27,7 +27,7 @@ import type { ShellLanguageOptions } from '../../language/shell-language-options
27
27
  *
28
28
  * @internal
29
29
  */
30
- export type CompatFeature = 'appendAllRedirect' | 'associativeArrays' | 'caseAttributes' | 'caseFallthrough' | 'caseModification' | 'coproc' | 'declareGlobal' | 'doubleBracket' | 'epochVariables' | 'fdVariable' | 'globstar' | 'lastpipe' | 'letCommand' | 'mapfile' | 'nameref' | 'negativeArrayIndex' | 'nounsetEmptyArray' | 'parameterTransformation' | 'parameterTransformationCase' | 'parameterTransformationKeys' | 'parameterTransformationQuotedKeys' | 'pipeAll' | 'printfTimeFormat' | 'sourceCommand' | 'testVariableSet' | 'unicodeEscape' | 'unicodeEscapeLatin1' | 'waitNext';
30
+ export type CompatFeature = 'appendAllRedirect' | 'associativeArrays' | 'braceSequencePaddingStep' | 'caseAttributes' | 'caseFallthrough' | 'caseModification' | 'coproc' | 'declareGlobal' | 'doubleBracket' | 'epochVariables' | 'fdVariable' | 'globstar' | 'lastpipe' | 'letCommand' | 'mapfile' | 'nameref' | 'negativeArrayIndex' | 'nounsetEmptyArray' | 'parameterTransformation' | 'parameterTransformationCase' | 'parameterTransformationKeys' | 'parameterTransformationQuotedKeys' | 'pipeAll' | 'printfTimeFormat' | 'sourceCommand' | 'testVariableSet' | 'unicodeEscape' | 'unicodeEscapeLatin1' | 'waitNext';
31
31
  /**
32
32
  * The two `languageOptions` fields gating reads. Both the raw and the
33
33
  * normalized options satisfy it; an unset `dialect` means `"bash"`, as it
@@ -35,28 +35,28 @@ export type CompatFeature = 'appendAllRedirect' | 'associativeArrays' | 'caseAtt
35
35
  *
36
36
  * @internal
37
37
  */
38
- export type GateLanguageOptions = Pick<ShellLanguageOptions, 'dialect' | 'shellVersion'>;
38
+ export type GateLanguageOptions = Pick<ShellLanguageOptions, 'dialect' | 'minBashVersion'>;
39
39
  /**
40
40
  * Whether `feature` is available to a file linted with `languageOptions`.
41
41
  *
42
42
  * - `dialect: "posix"`: available only when the gate's `posix` is `true`,
43
- * at any `shellVersion` (a Bash-only feature is never available).
44
- * - `dialect: "bash"` (the default) or `"bats"`: an unset `shellVersion`
45
- * means the newest Bash, so every Bash feature is available. A set
46
- * `shellVersion` makes the feature available when it is the gate's `bash`
43
+ * regardless of Bash releases (a Bash-only feature is never available).
44
+ * - `dialect: "bash"` (the default) or `"bats"`: an unset `minBashVersion`
45
+ * means no Bash version constraint, so every Bash feature is available. A set
46
+ * `minBashVersion` makes the feature available when it is the gate's `bash`
47
47
  * minimum or newer, comparing major then minor numerically (`"4"` is
48
48
  * `4.0`; `"3.10"` is newer than `"3.2"`). A `null` minimum is available at
49
49
  * every version.
50
50
  * - `dialect: "mksh"` or `"zsh"`: the table carries no data for these
51
- * shells, and their `shellVersion` numbers their own releases, not Bash's,
52
- * so no gate applies and every feature is available.
51
+ * shells. Config validation rejects `minBashVersion` for them; no Bash
52
+ * gate applies and every feature is available.
53
53
  *
54
54
  * @internal
55
55
  */
56
56
  export declare function isFeatureAvailable(feature: CompatFeature, languageOptions: GateLanguageOptions): boolean;
57
57
  /**
58
58
  * Human-readable words for a gate's report: what the feature needs
59
- * (`required`, such as `bash 4.0 or newer`) and what the file targets
59
+ * (`required`, such as `bash 4.0 or newer`) and what the file must support
60
60
  * (`target`, such as `bash 3.2` or `POSIX sh`). Compat rule messages
61
61
  * interpolate both as `{{required}}` and `{{target}}`.
62
62
  *
@@ -11,6 +11,14 @@ const FEATURE_GATES = Object.freeze({
11
11
  appendAllRedirect: Object.freeze({ bash: '4.0', posix: false }),
12
12
  /** `declare -A` / `typeset -A`: Bash 4.0 NEWS, "associative arrays". */
13
13
  associativeArrays: Object.freeze({ bash: '4.0', posix: false }),
14
+ /**
15
+ * Zero-padded (`{01..10}`) and stepped (`{1..10..2}`, `{a..e..2}`) brace
16
+ * sequences: Bash 4.0 NEWS (brace expansion gained zero-padding and an
17
+ * increment). Bash 3.2 expands the padded form without the padding and
18
+ * leaves the stepped form as a literal word; POSIX sh has no brace
19
+ * expansion.
20
+ */
21
+ braceSequencePaddingStep: Object.freeze({ bash: '4.0', posix: false }),
14
22
  /**
15
23
  * The case-modifying expansions `${v^}`, `${v^^}`, `${v,}`, `${v,,}`: Bash
16
24
  * 4.0 NEWS, "new case-modifying word expansions".
@@ -143,7 +151,7 @@ function compareVersions(a, b) {
143
151
  return a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1];
144
152
  }
145
153
  /**
146
- * The dialects whose `shellVersion` is a Bash version. `bats` test files run
154
+ * The dialects whose `minBashVersion` is a Bash version. `bats` test files run
147
155
  * under Bash, so they are gated exactly like `bash` files.
148
156
  */
149
157
  const BASH_DIALECTS = new Set(['bash', 'bats']);
@@ -151,16 +159,16 @@ const BASH_DIALECTS = new Set(['bash', 'bats']);
151
159
  * Whether `feature` is available to a file linted with `languageOptions`.
152
160
  *
153
161
  * - `dialect: "posix"`: available only when the gate's `posix` is `true`,
154
- * at any `shellVersion` (a Bash-only feature is never available).
155
- * - `dialect: "bash"` (the default) or `"bats"`: an unset `shellVersion`
156
- * means the newest Bash, so every Bash feature is available. A set
157
- * `shellVersion` makes the feature available when it is the gate's `bash`
162
+ * regardless of Bash releases (a Bash-only feature is never available).
163
+ * - `dialect: "bash"` (the default) or `"bats"`: an unset `minBashVersion`
164
+ * means no Bash version constraint, so every Bash feature is available. A set
165
+ * `minBashVersion` makes the feature available when it is the gate's `bash`
158
166
  * minimum or newer, comparing major then minor numerically (`"4"` is
159
167
  * `4.0`; `"3.10"` is newer than `"3.2"`). A `null` minimum is available at
160
168
  * every version.
161
169
  * - `dialect: "mksh"` or `"zsh"`: the table carries no data for these
162
- * shells, and their `shellVersion` numbers their own releases, not Bash's,
163
- * so no gate applies and every feature is available.
170
+ * shells. Config validation rejects `minBashVersion` for them; no Bash
171
+ * gate applies and every feature is available.
164
172
  *
165
173
  * @internal
166
174
  */
@@ -171,14 +179,14 @@ export function isFeatureAvailable(feature, languageOptions) {
171
179
  return gate.posix;
172
180
  if (!BASH_DIALECTS.has(dialect))
173
181
  return true;
174
- const { shellVersion } = languageOptions;
175
- if (shellVersion === undefined || gate.bash === null)
182
+ const { minBashVersion } = languageOptions;
183
+ if (minBashVersion === undefined || gate.bash === null)
176
184
  return true;
177
- const target = parseVersion(shellVersion);
185
+ const floor = parseVersion(minBashVersion);
178
186
  const minimum = parseVersion(gate.bash);
179
- if (target === undefined || minimum === undefined)
187
+ if (floor === undefined || minimum === undefined)
180
188
  return true;
181
- return compareVersions(target, minimum) >= 0;
189
+ return compareVersions(floor, minimum) >= 0;
182
190
  }
183
191
  /**
184
192
  * Describes why `feature` is unavailable under `languageOptions`, for a
@@ -192,6 +200,6 @@ export function describeGate(feature, languageOptions) {
192
200
  const required = gate.bash === null ? 'bash' : `bash ${gate.bash} or newer`;
193
201
  const target = (languageOptions.dialect ?? 'bash') === 'posix'
194
202
  ? 'POSIX sh'
195
- : `bash ${languageOptions.shellVersion ?? ''}`.trim();
203
+ : `bash ${languageOptions.minBashVersion ?? ''}`.trim();
196
204
  return { required, target };
197
205
  }
@@ -37,6 +37,7 @@
37
37
  * this analysis and are out of scope.
38
38
  */
39
39
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
40
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
40
41
  import { definedFunctionNames } from '../command-harvest/harvest-commands.js';
41
42
  import { createRule, packageDocsUrl } from './create-rule.js';
42
43
  /**
@@ -187,12 +188,13 @@ function staticText(word) {
187
188
  * Resolves `site`'s chain through `compiled`'s wrapper table, following a
188
189
  * wrapper invoked by path as the wrapper it names.
189
190
  *
190
- * `resolveArgv0` matches wrapper names exactly, so a path to a wrapper
191
- * (`/usr/local/bin/retry`) ends the chain as if it were the command run. When
192
- * the chain ends at a static word with a slash whose basename names a table
193
- * entry, the site is resolved again with that entry also recognized under the
191
+ * `resolveArgv0` follows absolute wrapper paths directly. Relative wrapper
192
+ * paths also count for this policy: when the chain ends at a static word
193
+ * with a slash whose basename names a table
194
+ * utility entry, the site is resolved again with that entry also recognized under the
194
195
  * exact path text, so its options and wrapped command are located by the
195
- * same analysis. Each pass adds one new name drawn from the site's own argv,
196
+ * same analysis. Shell builtin entries require exact names and never gain
197
+ * path spellings from this policy. Each pass adds one new name drawn from the site's own argv,
196
198
  * so the loop ends.
197
199
  */
198
200
  function resolveChain(compiled, site) {
@@ -202,7 +204,7 @@ function resolveChain(compiled, site) {
202
204
  const last = staticText(resolution.chain.at(-1));
203
205
  if (last === null || table.some((spec) => spec.names.includes(last)))
204
206
  return resolution;
205
- const spec = compiled.transparentWrappers.find((entry) => matchName(last, new Set(entry.names)) !== null);
207
+ const spec = compiled.transparentWrappers.find((entry) => entry.shellBuiltin !== true && matchName(last, new Set(entry.names)) !== null);
206
208
  if (spec === undefined)
207
209
  return resolution;
208
210
  table = [{ ...spec, names: [last] }, ...table];
@@ -365,7 +367,10 @@ export function buildWrapperRule(config) {
365
367
  schema: [],
366
368
  messages: WRAPPER_RULE_MESSAGES,
367
369
  },
368
- create: (context) => wrapperRuleListeners(compiled, context),
370
+ create: (context) => wrapperRuleListeners({
371
+ ...compiled,
372
+ transparentWrappers: resolveTransparentWrappers(context, compiled.transparentWrappers),
373
+ }, context),
369
374
  });
370
375
  }
371
376
  /**
@@ -1,5 +1,6 @@
1
1
  import { noAppendAllRedirect } from './compat/no-append-all-redirect.js';
2
2
  import { noAssociativeArrays } from './compat/no-associative-arrays.js';
3
+ import { noBraceSequencePaddingStep } from './compat/no-brace-sequence-padding-step.js';
3
4
  import { noCaseAttributes } from './compat/no-case-attributes.js';
4
5
  import { noCaseFallthrough } from './compat/no-case-fallthrough.js';
5
6
  import { noCaseModification } from './compat/no-case-modification.js';
@@ -47,6 +48,7 @@ import { shebangMatchesDialect } from './shebang-matches-dialect.js';
47
48
  export const rules = {
48
49
  'compat/no-append-all-redirect': noAppendAllRedirect,
49
50
  'compat/no-associative-arrays': noAssociativeArrays,
51
+ 'compat/no-brace-sequence-padding-step': noBraceSequencePaddingStep,
50
52
  'compat/no-case-attributes': noCaseAttributes,
51
53
  'compat/no-case-fallthrough': noCaseFallthrough,
52
54
  'compat/no-case-modification': noCaseModification,
@@ -18,14 +18,6 @@ export interface NoDynamicInvocationOptions {
18
18
  * `dynamicInvocation`. Embedded commands still report.
19
19
  */
20
20
  readonly allowInFunctions?: readonly string[];
21
- /**
22
- * Command names to treat as transparent wrappers, in addition to the
23
- * built-in list: the word after one is the command that runs. Each is
24
- * followed by name only, with no options of its own, so a flag after it
25
- * makes the effective command unknown. A name already in the built-in list
26
- * keeps its built-in option handling.
27
- */
28
- readonly allowWrappers?: readonly string[];
29
21
  }
30
22
  /**
31
23
  * The `sh/no-dynamic-invocation` rule module.
@@ -22,7 +22,7 @@
22
22
  * reports as `dynamicInvocation`.
23
23
  * - The wrappers followed are `sh-ast/analyze`'s defaults, Bash's `builtin`,
24
24
  * shells invoked as commands (so `bash -c` is an embedded command and
25
- * `bash script.sh` runs `script.sh`), and the names in `allowWrappers`.
25
+ * `bash script.sh` runs `script.sh`), and the shared project wrapper facts.
26
26
  *
27
27
  * The analysis is syntactic by design: `cmd=ls; "$cmd"` reports, because
28
28
  * resolving it would need dataflow. Aliases need no handling: they are not
@@ -31,16 +31,12 @@
31
31
  * command is a human decision.
32
32
  */
33
33
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
34
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
34
35
  import { createRule } from './create-rule.js';
35
36
  import { BUILTIN_WRAPPER, SHELL_WRAPPER } from './transparent-wrappers.js';
36
37
  /** The registry name of this rule (`sh/no-dynamic-invocation` in a user's config). */
37
38
  const RULE_NAME = 'no-dynamic-invocation';
38
- /**
39
- * The wrappers every invocation is followed through before any configured in
40
- * `allowWrappers`: `sh-ast/analyze`'s defaults, then Bash's `builtin`, then
41
- * shells invoked as commands. `resolveArgv0` uses the first entry whose name
42
- * matches, so the built-in entries take precedence over a user's.
43
- */
39
+ /** Default scope for dynamic invocations, including builtin and shell entrypoints. */
44
40
  const BUILT_IN_WRAPPERS = [
45
41
  ...DEFAULT_TRANSPARENT_WRAPPERS,
46
42
  BUILTIN_WRAPPER,
@@ -78,12 +74,11 @@ export const noDynamicInvocation = createRule({
78
74
  type: 'object',
79
75
  properties: {
80
76
  allowInFunctions: NAME_LIST_SCHEMA,
81
- allowWrappers: NAME_LIST_SCHEMA,
82
77
  },
83
78
  additionalProperties: false,
84
79
  },
85
80
  ],
86
- defaultOptions: [{ allowInFunctions: [], allowWrappers: [] }],
81
+ defaultOptions: [{ allowInFunctions: [] }],
87
82
  messages: {
88
83
  dynamicInvocation: 'The command this runs is not known until run time, so the script cannot be statically reviewed. Name the command with a literal word instead.',
89
84
  embeddedCommand: 'This runs shell code embedded in a string, which cannot be statically reviewed. Write the commands directly instead.',
@@ -91,13 +86,10 @@ export const noDynamicInvocation = createRule({
91
86
  },
92
87
  create(context) {
93
88
  const { sourceCode } = context;
94
- // `defaultOptions` guarantees the options object; either list may still be omitted in it.
95
- const [{ allowInFunctions = [], allowWrappers = [] }] = context.options;
89
+ // The options object exists by default; its dispatcher list may still be omitted.
90
+ const [{ allowInFunctions = [] }] = context.options;
96
91
  const dispatchers = new Set(allowInFunctions);
97
- const transparentWrappers = [
98
- ...BUILT_IN_WRAPPERS,
99
- ...allowWrappers.map((name) => ({ names: [name] })),
100
- ];
92
+ const transparentWrappers = resolveTransparentWrappers(context, BUILT_IN_WRAPPERS);
101
93
  return {
102
94
  File() {
103
95
  for (const site of sourceCode.getCommandSites()) {
@@ -25,6 +25,7 @@
25
25
  * suggestion, because no mechanical rewrite of `eval` preserves behavior.
26
26
  */
27
27
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
28
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
28
29
  import { createRule } from './create-rule.js';
29
30
  import { BUILTIN_WRAPPER } from './transparent-wrappers.js';
30
31
  /** The registry name of this rule (`sh/no-eval` in a user's config). */
@@ -60,10 +61,11 @@ export const noEval = createRule({
60
61
  },
61
62
  create(context) {
62
63
  const { sourceCode } = context;
64
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
63
65
  return {
64
66
  CallExpr(node) {
65
67
  const invoked = sourceCode.resolveInvokedCommand(node, {
66
- transparentWrappers: TRANSPARENT_WRAPPERS,
68
+ transparentWrappers,
67
69
  });
68
70
  if (!invoked?.effective.static) {
69
71
  return;
@@ -15,7 +15,8 @@
15
15
  * Real shells give a plain assignment the status of a substitution in any of
16
16
  * those places, so the declaration hides it from all of them. A process
17
17
  * substitution (`<(…)`) is not looked into: it expands to a path. The builtin
18
- * is also read behind the transparent wrappers `builtin` and `command`. One
18
+ * is also read behind the bare in-process wrappers `builtin` and `command`;
19
+ * paths to external executables are not declaration wrappers. One
19
20
  * report is made per operand, on the operand. An array value
20
21
  * (`local -a a=($(cmd))`) is not reported: shellcheck 0.11.0 does not report
21
22
  * it either, and a status-bearing scalar is the construct the rule exists for.
@@ -84,6 +85,7 @@
84
85
  * split into the declaration, the assignment, then `readonly <names>`.
85
86
  */
86
87
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
88
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
87
89
  import { childNodes } from '../language/syntax-view.js';
88
90
  import { lineBreakOf } from '../language/line-breaks.js';
89
91
  import { createRule } from './create-rule.js';
@@ -218,10 +220,13 @@ function collectFunctionNames(node, into) {
218
220
  * earlier, so such code can see a different variable afterwards. A function
219
221
  * defined in another file cannot be seen and is not detected.
220
222
  */
221
- function runsUnseenCode(node, functionNames, sourceCode) {
223
+ function runsUnseenCode(node, functionNames, sourceCode, transparentWrappers) {
222
224
  if (node.type === 'CallExpr' && node.args.length > 0) {
225
+ // This check names the launched program; opaque child-interpreter text
226
+ // does not turn its program identity into dynamic lookup.
223
227
  const invoked = sourceCode.resolveInvokedCommand(node, {
224
- transparentWrappers: TRANSPARENT_WRAPPERS,
228
+ transparentWrappers,
229
+ embeddedCommandInterpreters: [],
225
230
  });
226
231
  if (invoked?.effective.static !== true)
227
232
  return true;
@@ -229,7 +234,7 @@ function runsUnseenCode(node, functionNames, sourceCode) {
229
234
  if (CODE_RUNNING_BUILTINS.has(name) || functionNames.has(name))
230
235
  return true;
231
236
  }
232
- return childNodes(node).some((child) => runsUnseenCode(child, functionNames, sourceCode));
237
+ return childNodes(node).some((child) => runsUnseenCode(child, functionNames, sourceCode, transparentWrappers));
233
238
  }
234
239
  /**
235
240
  * The state of `errexit` before each top-level statement of `file`, from the
@@ -446,7 +451,7 @@ function withoutReadonly(flag) {
446
451
  * The replacement text for a standalone declaration, or `null` where no
447
452
  * equivalent split exists. `separator` joins the statements.
448
453
  */
449
- function splitText(declaration, scoped, separator, functionNames, sourceCode) {
454
+ function splitText(declaration, scoped, separator, functionNames, sourceCode, transparentWrappers) {
450
455
  const { variant, flags, operands, flagsKnown } = declaration;
451
456
  if (!flagsKnown || operands.some((operand) => operand.unsplittable))
452
457
  return null;
@@ -489,7 +494,7 @@ function splitText(declaration, scoped, separator, functionNames, sourceCode) {
489
494
  // operand declares nothing that code could observe differently.
490
495
  const mayObserveDeclaration = scoped || operands.length > 1 || flags.length > 0;
491
496
  if (mayObserveDeclaration &&
492
- operands.some((operand) => operand.values.some((value) => runsUnseenCode(value, functionNames, sourceCode)))) {
497
+ operands.some((operand) => operand.values.some((value) => runsUnseenCode(value, functionNames, sourceCode, transparentWrappers)))) {
493
498
  return null;
494
499
  }
495
500
  const makesReadonly = isReadonlyBuiltin || flags.some(isReadonlyFlag);
@@ -567,6 +572,7 @@ export const noMaskedExitStatus = createRule({
567
572
  create(context) {
568
573
  const [options] = context.options;
569
574
  const { sourceCode } = context;
575
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
570
576
  const checked = new Set(options.builtins ?? ALL_BUILTINS);
571
577
  const parentOf = (node) => sourceCode.getParent(node);
572
578
  let errexitBefore = new Map();
@@ -613,7 +619,7 @@ export const noMaskedExitStatus = createRule({
613
619
  const indent = text.slice(lineStart + 1, start);
614
620
  const separator = /^[ \t]*$/u.test(indent) ? lineBreakOf(text) + indent : '; ';
615
621
  const scoped = SCOPED_BUILTINS.has(builtin) && inFunction;
616
- suggestion = splitText(declaration, scoped, separator, functionNames, sourceCode);
622
+ suggestion = splitText(declaration, scoped, separator, functionNames, sourceCode, transparentWrappers);
617
623
  }
618
624
  for (const operand of substituted) {
619
625
  context.report({
@@ -645,7 +651,8 @@ export const noMaskedExitStatus = createRule({
645
651
  },
646
652
  CallExpr(node) {
647
653
  const invoked = sourceCode.resolveInvokedCommand(node, {
648
- transparentWrappers: TRANSPARENT_WRAPPERS,
654
+ transparentWrappers,
655
+ embeddedCommandInterpreters: [],
649
656
  });
650
657
  if (invoked?.effective.static !== true)
651
658
  return;
@@ -5,13 +5,14 @@
5
5
  */
6
6
  export interface NoSecretEnvReadsOptions {
7
7
  /**
8
- * Regex sources tested against each expanded variable's name. Replaces
8
+ * Case-sensitive Unicode regex sources tested against each expanded
9
+ * variable's name. Replaces
9
10
  * {@link DEFAULT_PATTERNS} entirely.
10
11
  */
11
12
  readonly patterns?: readonly string[];
12
13
  /**
13
- * Command names whose arguments may read a secret-shaped variable. A
14
- * command run by path matches by basename.
14
+ * Command names whose own arguments may read a secret-shaped variable. Wrapper-owned operands are still checked normally. A command
15
+ * run by path matches by basename.
15
16
  */
16
17
  readonly allowedSinks?: readonly string[];
17
18
  }
@@ -22,12 +22,14 @@
22
22
  * command that runs (`TOKEN=$GITHUB_TOKEN tool`) is the recommended
23
23
  * pattern and is clean. A read nested deeper (a command substitution in
24
24
  * that value) belongs to the inner command and is judged there.
25
- * - A read in the arguments of an allow-listed sink command is clean. The
26
- * command is the nearest enclosing `CallExpr`, resolved through
27
- * transparent wrappers by `ShellSourceCode#resolveInvokedCommand`.
25
+ * - A read in an argument after the final allow-listed sink command is clean.
26
+ * Wrapper-owned flags and operands receive no sink exemption. The nearest
27
+ * enclosing `CallExpr` supplies argv ownership, and its resolved final
28
+ * command index separates wrapper operands from the sink's arguments.
28
29
  * - Matching is a name pattern, not secret detection.
29
30
  */
30
31
  import { AlternateUnset, AlternateUnsetOrNull } from 'sh-ast/analyze';
32
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
31
33
  import { createRule } from './create-rule.js';
32
34
  import { compileUserPattern } from './user-pattern.js';
33
35
  /**
@@ -71,7 +73,9 @@ export const noSecretEnvReads = createRule({
71
73
  properties: {
72
74
  patterns: {
73
75
  type: 'array',
74
- items: { type: 'string', format: 'regex' },
76
+ // The shared compiler validates Unicode syntax and identifies
77
+ // the option index and source; flagless schema checks cannot.
78
+ items: { type: 'string' },
75
79
  minItems: 1,
76
80
  },
77
81
  allowedSinks: {
@@ -89,33 +93,52 @@ export const noSecretEnvReads = createRule({
89
93
  },
90
94
  create(context) {
91
95
  const options = context.options[0];
92
- const patterns = (options?.patterns ?? DEFAULT_PATTERNS).map((source, index) => compileUserPattern('sh/no-secret-env-reads', `patterns[${String(index)}]`, source, ''));
96
+ const patterns = (options?.patterns ?? DEFAULT_PATTERNS).map((source, index) => compileUserPattern('sh/no-secret-env-reads', `patterns[${String(index)}]`, source, 'u'));
93
97
  const allowedSinks = new Set(options?.allowedSinks ?? []);
94
98
  const { sourceCode } = context;
99
+ const transparentWrappers = resolveTransparentWrappers(context);
95
100
  const isSecretName = (name) => patterns.some((pattern) => pattern.test(name));
96
- /** Whether the command that `callExpr` runs, wrappers followed, is an allowed sink. */
97
- function isAllowedSink(callExpr) {
98
- if (allowedSinks.size === 0)
101
+ /** Whether this direct argument word belongs to the final approved sink. */
102
+ function isAllowedSink(callExpr, argument) {
103
+ if (allowedSinks.size === 0 || argument.type !== 'Word') {
104
+ return false;
105
+ }
106
+ // Sink policy names the program receiving these arguments. An interpreter's
107
+ // command-string selector does not change that recipient; judging its code
108
+ // belongs to no-dynamic-invocation rather than this explicit allowance.
109
+ const invoked = sourceCode.resolveInvokedCommand(callExpr, {
110
+ transparentWrappers,
111
+ embeddedCommandInterpreters: [],
112
+ });
113
+ if (!invoked?.effective.static) {
99
114
  return false;
100
- const effective = sourceCode.resolveInvokedCommand(callExpr)?.effective;
101
- if (!effective?.static)
115
+ }
116
+ const effectiveIndex = invoked.chainIndices.at(-1);
117
+ if (effectiveIndex === undefined || callExpr.args.indexOf(argument) <= effectiveIndex) {
102
118
  return false;
119
+ }
120
+ const { effective } = invoked;
103
121
  return allowedSinks.has(effective.text) || allowedSinks.has(basename(effective.text));
104
122
  }
105
123
  /**
106
- * Whether the read at `node` is permitted: it sits in the arguments of an
107
- * allowed sink, or directly in an assignment prefix of a command that runs.
124
+ * Whether the read at `node` is permitted: it sits in an actual argument of
125
+ * the final allowed sink, or in an assignment prefix of a command that runs.
108
126
  * The nearest enclosing `Assign` or `CallExpr` decides, so a read inside a
109
127
  * command substitution is judged by the inner command, not the outer one.
110
128
  */
111
129
  function isPermittedRead(node) {
130
+ // Keep the direct child by identity: argument ownership needs no range
131
+ // arithmetic and nested commands retain their own nearest call boundary.
132
+ let child = node;
112
133
  for (let up = sourceCode.getParent(node); up !== undefined; up = sourceCode.getParent(up)) {
113
- if (up.type === 'CallExpr')
114
- return isAllowedSink(up);
134
+ if (up.type === 'CallExpr') {
135
+ return isAllowedSink(up, child);
136
+ }
115
137
  if (up.type === 'Assign') {
116
138
  const owner = sourceCode.getParent(up);
117
139
  return owner?.type === 'CallExpr' && owner.args.length > 0;
118
140
  }
141
+ child = up;
119
142
  }
120
143
  return false;
121
144
  }
@@ -7,7 +7,8 @@ import type { ShellNode } from '../language/syntax-view.js';
7
7
  export interface NoSecretFlagsOptions {
8
8
  /**
9
9
  * Regex sources tested against each flag's name (the word up to, not
10
- * including, its first `=`). Replaces {@link DEFAULT_PATTERNS} entirely.
10
+ * including, its first `=`), with Unicode case-insensitive matching.
11
+ * Replaces {@link DEFAULT_PATTERNS} entirely.
11
12
  */
12
13
  readonly patterns?: readonly string[];
13
14
  }
@@ -55,14 +55,11 @@ const DEFAULT_PATTERNS = [
55
55
  '^(?!.*-(?:stdin|file)$)--?(?:access-|auth-?)?(?:password|passwd|token|secret|api-?key|auth(?:key|orization)?)(?![a-z])',
56
56
  ];
57
57
  /**
58
- * The flags every pattern is compiled with. Case-insensitive, matching the
59
- * default pattern's `/i`. No `u` flag: config validation checks each source
60
- * with ESLint's JSON-schema `regex` format, which compiles without flags, and
61
- * a few sources valid there are syntax errors under `u`. Adding `i` never
62
- * turns a valid source into an invalid one, so a pattern that passed
63
- * validation always compiles here.
58
+ * Unicode matching that ignores case for both default and custom sources.
59
+ * Keep the original flag name and pattern intact: lower-casing the subject
60
+ * would make a custom pattern containing capitals silently stop matching.
64
61
  */
65
- const PATTERN_FLAGS = 'i';
62
+ const PATTERN_FLAGS = 'iu';
66
63
  /**
67
64
  * Splits a static argument word into the flag name the patterns are tested
68
65
  * against and the value written in the same word, if any. `--token=abc` is
@@ -100,7 +97,9 @@ export const noSecretFlags = createRule({
100
97
  properties: {
101
98
  patterns: {
102
99
  type: 'array',
103
- items: { type: 'string', format: 'regex' },
100
+ // Syntax is checked with the actual flags by compileUserPattern,
101
+ // which also names the option index and source in errors.
102
+ items: { type: 'string' },
104
103
  minItems: 1,
105
104
  },
106
105
  },
@@ -48,6 +48,7 @@
48
48
  * statement is the document body, not shell code).
49
49
  */
50
50
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
51
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
51
52
  import { createRule } from './create-rule.js';
52
53
  import { prologueEnabledOptions, setStatementChanges } from './require-strict-mode.js';
53
54
  import { BUILTIN_WRAPPER } from './transparent-wrappers.js';
@@ -202,6 +203,7 @@ export const noUncheckedCd = createRule({
202
203
  create(context) {
203
204
  const [options] = context.options;
204
205
  const { sourceCode } = context;
206
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
205
207
  let errexitInForce = false;
206
208
  return {
207
209
  File(node) {
@@ -213,7 +215,7 @@ export const noUncheckedCd = createRule({
213
215
  if (errexitInForce)
214
216
  return;
215
217
  const invoked = sourceCode.resolveInvokedCommand(node, {
216
- transparentWrappers: TRANSPARENT_WRAPPERS,
218
+ transparentWrappers,
217
219
  });
218
220
  if (invoked?.effective.static !== true || !DIRECTORY_COMMANDS.has(invoked.effective.text)) {
219
221
  return;
@@ -1,20 +1,6 @@
1
- /**
2
- * The rule's single options object.
3
- *
4
- * @internal
5
- */
6
- export interface NoUndeclaredCommandsOptions {
7
- /**
8
- * Names of project-specific transparent wrappers (`with-retry`), added to
9
- * the default wrapper table (see `harvestWrapperTable`). The word after a listed
10
- * wrapper is the command it runs, so `with-retry curl` uses `curl` as well
11
- * as `with-retry`.
12
- */
13
- readonly transparentWrappers?: readonly string[];
14
- }
15
1
  /**
16
2
  * The `sh/no-undeclared-commands` rule module.
17
3
  *
18
4
  * @internal
19
5
  */
20
- export declare const noUndeclaredCommands: import("./define-shell-rule.js").ShellRule<[NoUndeclaredCommandsOptions?], "undeclaredCommand">;
6
+ export declare const noUndeclaredCommands: import("./define-shell-rule.js").ShellRule<[], "undeclaredCommand">;
@@ -33,7 +33,7 @@ import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
33
33
  import { createRule } from './create-rule.js';
34
34
  import { readDeclaredCommands, toLintedFile, } from '../declaration-conventions/read-declared-commands.js';
35
35
  import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
36
- import { networkWrapperSpec } from './resolve-network-wrapper.js';
36
+ import { resolveTransparentWrappers } from './resolve-transparent-wrappers.js';
37
37
  /** The registry name of this rule (`sh/no-undeclared-commands` in a user's config). */
38
38
  const RULE_NAME = 'no-undeclared-commands';
39
39
  /** The report's second sentence when the convention offers no hint for a command. */
@@ -67,19 +67,7 @@ export const noUndeclaredCommands = createRule({
67
67
  docs: {
68
68
  description: 'Require every external command a script uses to be declared by the configured declaration convention',
69
69
  },
70
- schema: [
71
- {
72
- type: 'object',
73
- properties: {
74
- transparentWrappers: {
75
- type: 'array',
76
- items: { type: 'string', minLength: 1 },
77
- uniqueItems: true,
78
- },
79
- },
80
- additionalProperties: false,
81
- },
82
- ],
70
+ schema: [],
83
71
  messages: {
84
72
  undeclaredCommand: "'{{command}}' is used but not declared. {{howToDeclare}}",
85
73
  },
@@ -90,7 +78,7 @@ export const noUndeclaredCommands = createRule({
90
78
  return {};
91
79
  }
92
80
  const { sourceCode } = context;
93
- const transparentWrappers = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
81
+ const transparentWrappers = resolveTransparentWrappers(context, harvestWrapperTable(sourceCode.dialect));
94
82
  return {
95
83
  File() {
96
84
  // Only names matter here, so every kind of declaration counts the same.