@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
@@ -14,6 +14,7 @@
14
14
  * {@link DECLARATION_AND_LOCAL_COMMANDS}.
15
15
  */
16
16
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
17
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
17
18
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
18
19
  /** The declaration commands that take option clusters. */
19
20
  const DECLARATION_COMMANDS = new Set(['declare', 'typeset']);
@@ -78,7 +79,9 @@ function declClauseArgs(node) {
78
79
  *
79
80
  * @internal
80
81
  */
81
- export function declarationFlagVisitor(sourceCode, flag, report, commands = DECLARATION_COMMANDS) {
82
+ export function declarationFlagVisitor(context, flag, report, commands = DECLARATION_COMMANDS) {
83
+ const { sourceCode } = context;
84
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
82
85
  const flagPattern = new RegExp(`^-[a-zA-Z]*${flag}[a-zA-Z]*$`, 'u');
83
86
  return {
84
87
  DeclClause(node) {
@@ -91,7 +94,7 @@ export function declarationFlagVisitor(sourceCode, flag, report, commands = DECL
91
94
  },
92
95
  CallExpr(node) {
93
96
  const invoked = sourceCode.resolveInvokedCommand(node, {
94
- transparentWrappers: TRANSPARENT_WRAPPERS,
97
+ transparentWrappers,
95
98
  });
96
99
  if (invoked?.effective.static !== true)
97
100
  return;
@@ -37,7 +37,7 @@ export const noAppendAllRedirect = createCompatRule({
37
37
  description: 'Disallow the `&>>` redirection when the target shell lacks it',
38
38
  },
39
39
  messages: {
40
- appendAll: '`&>>` needs {{required}}, but this file targets {{target}}. Append both streams with `>> file 2>&1` instead.',
40
+ appendAll: '`&>>` needs {{required}}, but this file must support {{target}}. Append both streams with `>> file 2>&1` instead.',
41
41
  useAppendRedirects: 'Replace `&>> {{file}}` with `>> {{file}} 2>&1`.',
42
42
  },
43
43
  },
@@ -28,11 +28,11 @@ export const noAssociativeArrays = createCompatRule({
28
28
  description: 'Disallow associative arrays (`declare -A`, `typeset -A`) when the target shell lacks them',
29
29
  },
30
30
  messages: {
31
- associativeArray: 'Associative arrays (`{{command}} -A`) need {{required}}, but this file targets {{target}}. Consider moving the keyed lookup into `jq`, for example `jq -r --arg k "$key" \'.[$k]\' map.json`.',
31
+ associativeArray: 'Associative arrays (`{{command}} -A`) need {{required}}, but this file must support {{target}}. Consider moving the keyed lookup into `jq`, for example `jq -r --arg k "$key" \'.[$k]\' map.json`.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
35
- return declarationFlagVisitor(context.sourceCode, 'A', (node, command) => {
35
+ return declarationFlagVisitor(context, 'A', (node, command) => {
36
36
  context.report({ node, messageId: 'associativeArray', data: { ...gate, command } });
37
37
  });
38
38
  },
@@ -236,10 +236,10 @@ export const noBraceSequencePaddingStep = createCompatRule({
236
236
  description: 'Disallow zero-padded (`{01..10}`) and stepped (`{1..10..2}`) brace sequences when the target shell lacks them',
237
237
  },
238
238
  messages: {
239
- padded: '`{{sequence}}` needs {{required}}, but this file targets {{target}}, which expands it without the zero padding. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
240
- stepped: '`{{sequence}}` needs {{required}}, but this file targets {{target}}, which leaves it as that literal word. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
241
- posixPadded: '`{{sequence}}` needs {{required}}, but this file targets {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
242
- posixStepped: '`{{sequence}}` needs {{required}}, but this file targets {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
239
+ padded: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which expands it without the zero padding. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
240
+ stepped: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which leaves it as that literal word. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
241
+ posixPadded: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop and format with `printf` instead, for example `i=1; while [ "$i" -le 3 ]; do printf \'%02d\\n\' "$i"; i=$((i + 1)); done`.',
242
+ posixStepped: '`{{sequence}}` needs {{required}}, but this file must support {{target}}, which does not specify brace expansion, so what a shell does with this word depends on the shell. Count in a loop instead, for example `i=1; while [ "$i" -le 9 ]; do echo "$i"; i=$((i + 4)); done`.',
243
243
  },
244
244
  },
245
245
  create(context, gate) {
@@ -36,13 +36,13 @@ export const noCaseAttributes = createCompatRule({
36
36
  description: 'Disallow the case attributes `declare -l` and `declare -u` when the target shell lacks them',
37
37
  },
38
38
  messages: {
39
- caseAttribute: '`{{command}} -{{flag}}` needs {{required}}, but this file targets {{target}}. Convert the value when you assign it instead, for example `v=$(printf \'%s\' "$v" | {{tr}})`.',
40
- caseAttributesBoth: '`{{command}} {{option}}` needs {{required}}, but this file targets {{target}}. Where it is supported, `-l` and `-u` together cancel out and leave the value as written, so drop both letters.',
39
+ caseAttribute: '`{{command}} -{{flag}}` needs {{required}}, but this file must support {{target}}. Convert the value when you assign it instead, for example `v=$(printf \'%s\' "$v" | {{tr}})`.',
40
+ caseAttributesBoth: '`{{command}} {{option}}` needs {{required}}, but this file must support {{target}}. Where it is supported, `-l` and `-u` together cancel out and leave the value as written, so drop both letters.',
41
41
  },
42
42
  },
43
43
  create(context, gate) {
44
44
  const { sourceCode } = context;
45
- return declarationFlagVisitor(sourceCode, '[lu]', (node, command) => {
45
+ return declarationFlagVisitor(context, '[lu]', (node, command) => {
46
46
  const option = (node.type === 'Word' ? sourceCode.resolveWordText(node) : null) ?? '';
47
47
  if (option.includes('l') && option.includes('u')) {
48
48
  context.report({
@@ -37,8 +37,8 @@ export const noCaseFallthrough = createCompatRule({
37
37
  description: 'Disallow the `case` terminators `;&` and `;;&` when the target shell lacks them',
38
38
  },
39
39
  messages: {
40
- fallthrough: "`;&` needs {{required}}, but this file targets {{target}}. Repeat the next branch's commands at the end of this branch and end it with `;;` instead.",
41
- resume: '`;;&` needs {{required}}, but this file targets {{target}}. End this branch with `;;` and test the patterns that follow it in a second `case` statement instead.',
40
+ fallthrough: "`;&` needs {{required}}, but this file must support {{target}}. Repeat the next branch's commands at the end of this branch and end it with `;;` instead.",
41
+ resume: '`;;&` needs {{required}}, but this file must support {{target}}. End this branch with `;;` and test the patterns that follow it in a second `case` statement instead.',
42
42
  },
43
43
  },
44
44
  create(context, gate) {
@@ -55,8 +55,8 @@ export const noCaseModification = createCompatRule({
55
55
  description: 'Disallow case-modifying expansions (`${v^^}`, `${v,,}` and variants) when the target shell lacks them',
56
56
  },
57
57
  messages: {
58
- caseModification: '`${…{{operator}}}` needs {{required}}, but this file targets {{target}}. Convert the case with `tr` instead, for example {{example}}.',
59
- caseModificationPattern: '`${…{{operator}}pattern}` needs {{required}}, but this file targets {{target}}. It changes only the characters the pattern matches, so a plain case-converting `tr` is not equivalent; spell out the matching characters instead (for example `tr a A`).',
58
+ caseModification: '`${…{{operator}}}` needs {{required}}, but this file must support {{target}}. Convert the case with `tr` instead, for example {{example}}.',
59
+ caseModificationPattern: '`${…{{operator}}pattern}` needs {{required}}, but this file must support {{target}}. It changes only the characters the pattern matches, so a plain case-converting `tr` is not equivalent; spell out the matching characters instead (for example `tr a A`).',
60
60
  },
61
61
  },
62
62
  create(context, gate) {
@@ -28,7 +28,7 @@ export const noCoproc = createCompatRule({
28
28
  description: 'Disallow the `coproc` reserved word when the target shell lacks it',
29
29
  },
30
30
  messages: {
31
- coproc: '`coproc` needs {{required}}, but this file targets {{target}}. Use a named pipe instead: create it with `mkfifo`, start the coprocess in the background reading or writing the pipe, and open the pipe from the script.',
31
+ coproc: '`coproc` needs {{required}}, but this file must support {{target}}. Use a named pipe instead: create it with `mkfifo`, start the coprocess in the background reading or writing the pipe, and open the pipe from the script.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -39,12 +39,12 @@ export const noDeclareG = createCompatRule({
39
39
  description: 'Disallow `declare -g` and `typeset -g` when the target shell lacks the option',
40
40
  },
41
41
  messages: {
42
- declareGlobal: '`{{command}} -g` needs {{required}}, but this file targets {{target}}. Assign the global with a plain `name=value` (a variable not declared `local` is global), or `export name=value` to also export it.',
43
- declareGlobalWithAttributes: '`{{command}} {{flags}}` needs {{required}}, but this file targets {{target}}. A plain `name=value` cannot express its other options, so dropping `g` is not enough: declare the variable outside any function, or raise the target.',
42
+ declareGlobal: '`{{command}} -g` needs {{required}}, but this file must support {{target}}. Assign the global with a plain `name=value` (a variable not declared `local` is global), or `export name=value` to also export it.',
43
+ declareGlobalWithAttributes: '`{{command}} {{flags}}` needs {{required}}, but this file must support {{target}}. A plain `name=value` cannot express its other options, so dropping `g` is not enough: declare the variable outside any function, or raise the minimum Bash version.',
44
44
  },
45
45
  },
46
46
  create(context, gate) {
47
- return declarationFlagVisitor(context.sourceCode, 'g', (node, command, options) => {
47
+ return declarationFlagVisitor(context, 'g', (node, command, options) => {
48
48
  if (!isPlainAssignmentEquivalent(options)) {
49
49
  context.report({
50
50
  node,
@@ -39,10 +39,10 @@ export const noDoubleBracket = createCompatRule({
39
39
  },
40
40
  },
41
41
  create(context, gate) {
42
- const { sourceCode } = context;
42
+ const reachableWord = reachableCommandWord(context, '[[');
43
43
  return {
44
44
  CallExpr(node) {
45
- const word = reachableCommandWord(sourceCode, node, '[[');
45
+ const word = reachableWord(node);
46
46
  if (word === undefined)
47
47
  return;
48
48
  context.report({ node: word, messageId: 'doubleBracket', data: { target: gate.target } });
@@ -27,8 +27,8 @@ export const noEpochVariables = createCompatRule({
27
27
  description: 'Disallow `$EPOCHSECONDS` and `$EPOCHREALTIME` when the target shell lacks them',
28
28
  },
29
29
  messages: {
30
- epochSeconds: '`$EPOCHSECONDS` needs {{required}}, but this file targets {{target}}, where it is unset. Use `$(date +%s)` instead (GNU, BSD and BusyBox `date`; POSIX `date` has no `%s`).',
31
- epochRealtime: '`$EPOCHREALTIME` needs {{required}}, but this file targets {{target}}, where it is unset. `$(date +%s)` gives whole seconds (GNU, BSD and BusyBox `date`); there is no portable source of the fraction.',
30
+ epochSeconds: '`$EPOCHSECONDS` needs {{required}}, but this file must support {{target}}, where it is unset. Use `$(date +%s)` instead (GNU, BSD and BusyBox `date`; POSIX `date` has no `%s`).',
31
+ epochRealtime: '`$EPOCHREALTIME` needs {{required}}, but this file must support {{target}}, where it is unset. `$(date +%s)` gives whole seconds (GNU, BSD and BusyBox `date`); there is no portable source of the fraction.',
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -32,7 +32,7 @@ export const noFdVariable = createCompatRule({
32
32
  description: 'Disallow `{varname}` file-descriptor redirections (`exec {fd}>file`) when the target shell lacks them',
33
33
  },
34
34
  messages: {
35
- fdVariable: '`{{{name}}}` descriptor allocation needs {{required}}, but this file targets {{target}}. Use a fixed descriptor number instead, for example `exec 3>file`, then `>&3`, then `exec 3>&-`.',
35
+ fdVariable: '`{{{name}}}` descriptor allocation needs {{required}}, but this file must support {{target}}. Use a fixed descriptor number instead, for example `exec 3>file`, then `>&3`, then `exec 3>&-`.',
36
36
  },
37
37
  },
38
38
  create(context, gate) {
@@ -28,7 +28,7 @@ export const noGlobstar = createCompatRule({
28
28
  description: 'Disallow `shopt -s globstar` when the target shell lacks the option',
29
29
  },
30
30
  messages: {
31
- globstar: "`shopt -s globstar` needs {{required}}, but this file targets {{target}}, where `**` matches like `*`. Search the tree with `find` instead, for example `find dir -name '*.txt'`.",
31
+ globstar: "`shopt -s globstar` needs {{required}}, but this file must support {{target}}, where `**` matches like `*`. Search the tree with `find` instead, for example `find dir -name '*.txt'`.",
32
32
  },
33
33
  },
34
34
  create(context, gate) {
@@ -37,8 +37,8 @@ export const noLastpipeSemantics = createCompatRule({
37
37
  description: 'Disallow `shopt -s lastpipe` when the target shell lacks the option',
38
38
  },
39
39
  messages: {
40
- lastpipe: '`shopt -s lastpipe` needs {{required}}, but this file targets {{target}}. Keep the loop in the current shell by feeding it with process substitution instead, for example `while read -r line; do …; done < <(cmd)`.',
41
- lastpipePosix: '`shopt -s lastpipe` needs {{required}}, but this file targets {{target}}. Process substitution is not POSIX either; keep the loop in the current shell by writing the output to a temporary file and reading that, for example `cmd > "$tmp"` then `while read -r line; do …; done < "$tmp"`, or by feeding the loop a here-document.',
40
+ lastpipe: '`shopt -s lastpipe` needs {{required}}, but this file must support {{target}}. Keep the loop in the current shell by feeding it with process substitution instead, for example `while read -r line; do …; done < <(cmd)`.',
41
+ lastpipePosix: '`shopt -s lastpipe` needs {{required}}, but this file must support {{target}}. Process substitution is not POSIX either; keep the loop in the current shell by writing the output to a temporary file and reading that, for example `cmd > "$tmp"` then `while read -r line; do …; done < "$tmp"`, or by feeding the loop a here-document.',
42
42
  },
43
43
  },
44
44
  create(context, gate) {
@@ -47,9 +47,10 @@ export const noLet = createCompatRule({
47
47
  create(context, gate) {
48
48
  const { sourceCode } = context;
49
49
  const fileDefinesLet = definedFunctionNames(sourceCode).has(LET);
50
+ const reachableWord = reachableCommandWord(context, LET);
50
51
  return {
51
52
  CallExpr(node) {
52
- const word = reachableCommandWord(sourceCode, node, LET);
53
+ const word = reachableWord(node);
53
54
  if (word === undefined)
54
55
  return;
55
56
  // Only a name written directly can run a function: a wrapper skips it.
@@ -28,6 +28,7 @@
28
28
  * all, get the report without a suggestion.
29
29
  */
30
30
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
31
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
31
32
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
32
33
  import { createCompatRule } from './create-compat-rule.js';
33
34
  /** The builtin's two names. */
@@ -84,17 +85,18 @@ export const noMapfile = createCompatRule({
84
85
  description: 'Disallow `mapfile` and `readarray` when the target shell lacks them',
85
86
  },
86
87
  messages: {
87
- mapfile: '`{{command}}` needs {{required}}, but this file targets {{target}}. Read the lines with a `while IFS= read -r` loop instead.',
88
+ mapfile: '`{{command}}` needs {{required}}, but this file must support {{target}}. Read the lines with a `while IFS= read -r` loop instead.',
88
89
  useReadLoop: 'Replace with a `while IFS= read -r` loop that appends each line to `{{array}}` (the loop also sets `REPLY` to the empty string).',
89
90
  },
90
91
  },
91
92
  create(context, gate) {
92
93
  const { sourceCode } = context;
94
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
93
95
  const arraysAvailable = sourceCode.dialect !== 'posix';
94
96
  return {
95
97
  CallExpr(node) {
96
98
  const invoked = sourceCode.resolveInvokedCommand(node, {
97
- transparentWrappers: TRANSPARENT_WRAPPERS,
99
+ transparentWrappers,
98
100
  });
99
101
  if (invoked?.effective.static !== true)
100
102
  return;
@@ -30,14 +30,14 @@ export const noNameref = createCompatRule({
30
30
  description: 'Disallow namerefs (`declare -n`, `local -n`) when the target shell lacks them',
31
31
  },
32
32
  messages: {
33
- nameref: '`{{command}} -n` needs {{required}}, but this file targets {{target}}. Pass the variable name instead: assign through it with `printf -v "$name" \'%s\' "$value"` and read it with `${!name}`.',
34
- namerefPosix: '`{{command}} -n` needs {{required}}, but this file targets {{target}}. Pass the variable name instead and use `eval`: assign with `eval "$name=\\$value"` and read with `eval "value=\\$$name"`, after checking that the name is a valid identifier.',
33
+ nameref: '`{{command}} -n` needs {{required}}, but this file must support {{target}}. Pass the variable name instead: assign through it with `printf -v "$name" \'%s\' "$value"` and read it with `${!name}`.',
34
+ namerefPosix: '`{{command}} -n` needs {{required}}, but this file must support {{target}}. Pass the variable name instead and use `eval`: assign with `eval "$name=\\$value"` and read with `eval "value=\\$$name"`, after checking that the name is a valid identifier.',
35
35
  },
36
36
  },
37
37
  create(context, gate) {
38
38
  const { sourceCode } = context;
39
39
  const messageId = sourceCode.dialect === 'posix' ? 'namerefPosix' : 'nameref';
40
- return declarationFlagVisitor(sourceCode, 'n', (node, command) => {
40
+ return declarationFlagVisitor(context, 'n', (node, command) => {
41
41
  context.report({ node, messageId, data: { ...gate, command } });
42
42
  }, DECLARATION_AND_LOCAL_COMMANDS);
43
43
  },
@@ -15,12 +15,13 @@
15
15
  * so the two agree only for arrays without holes; the suggestion message says
16
16
  * so. Assignments and other expansion forms are reported without a suggestion.
17
17
  *
18
- * What is read: an `Index` that is a unary minus applied to a static decimal
19
- * integer, on a `ParamExp` or an `Assign`. A computed subscript such as
18
+ * What is read: an `Index` that is a unary minus applied to a static
19
+ * integer (decimal, octal, hexadecimal or base#digits), on a `ParamExp` or an `Assign`.
20
+ * A computed subscript such as
20
21
  * `-$n` is not a literal and is not reported, and neither is `-0`, which is
21
22
  * zero. A `ParamExp` or `Assign` subscript is read from the syntax tree, so
22
23
  * its operand can be any arithmetic node (`-(1)` holds a parenthesized one);
23
- * only a plain word is resolved.
24
+ * only a plain word is resolved. Arithmetic expressions are never evaluated.
24
25
  *
25
26
  * Unsetting an element (`unset arr[-1]`, quoted or not) is reported from the
26
27
  * `unset` command's operand text, since the parser keeps that operand as an
@@ -28,6 +29,7 @@
28
29
  */
29
30
  import { Minus } from 'sh-ast';
30
31
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
32
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
31
33
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
32
34
  import { createCompatRule } from './create-compat-rule.js';
33
35
  /** A bare negative-subscript read, the only shape the suggestion rewrites. */
@@ -41,7 +43,33 @@ const TRANSPARENT_WRAPPERS = [
41
43
  BUILTIN_WRAPPER,
42
44
  ];
43
45
  /** An `unset` operand naming an element by a literal negative subscript. */
44
- const UNSET_NEGATIVE = /^[A-Za-z_][A-Za-z0-9_]*\[\s*-\s*\d+\s*\]$/u;
46
+ const UNSET_NEGATIVE = /^[A-Za-z_][A-Za-z0-9_]*\[\s*-\s*([^\s\]]+)\s*\]$/u;
47
+ /**
48
+ * Recognizes a nonzero integer token using Bash's digit alphabets, without evaluating expressions.
49
+ * Leading zero means octal; base#digits requires an unpadded base from 2..64. Variables and invalid digits
50
+ * provide no static evidence. Machine-integer overflow is outside this lexical recognizer.
51
+ */
52
+ function isNonzeroInteger(text) {
53
+ if (/^0[xX][0-9a-fA-F]+$/u.test(text))
54
+ return /[1-9a-fA-F]/u.test(text.slice(2));
55
+ if (/^0[0-7]*$/u.test(text))
56
+ return /[1-7]/u.test(text);
57
+ if (/^[1-9][0-9]*$/u.test(text))
58
+ return true;
59
+ const based = /^([1-9]\d?)#([0-9a-zA-Z@_]+)$/u.exec(text);
60
+ if (based === null)
61
+ return false;
62
+ const base = Number(based[1]);
63
+ if (base < 2 || base > 64)
64
+ return false;
65
+ const digits = base <= 36 ? based[2].toLowerCase() : based[2];
66
+ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ@_'.slice(0, base);
67
+ for (const digit of digits) {
68
+ if (!alphabet.includes(digit))
69
+ return false;
70
+ }
71
+ return /[^0]/u.test(digits);
72
+ }
45
73
  /**
46
74
  * The `sh/compat/no-negative-array-index` rule module.
47
75
  *
@@ -57,13 +85,14 @@ export const noNegativeArrayIndex = createCompatRule({
57
85
  description: 'Disallow negative array subscripts (`${arr[-1]}`) when the target shell lacks them',
58
86
  },
59
87
  messages: {
60
- negativeIndex: 'Negative array subscripts need {{required}}, but this file targets {{target}}. Count from the length instead, for example `${arr[$((${#arr[@]}-1))]}`.',
88
+ negativeIndex: 'Negative array subscripts need {{required}}, but this file must support {{target}}. Count from the length instead, for example `${arr[$((${#arr[@]}-1))]}`.',
61
89
  useArrayLength: 'Replace with `${{{name}}[$((${#{{name}}[@]}-{{n}}))]}` (the two differ only for an array with unset elements).',
62
90
  },
63
91
  },
64
92
  create(context, gate) {
65
93
  const { sourceCode } = context;
66
- /** Whether `index` is a unary minus on a static decimal integer. */
94
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
95
+ /** Whether `index` is a unary minus on a nonzero static integer token. */
67
96
  function isNegativeLiteral(index) {
68
97
  if (index?.type !== 'UnaryArithm' || index.op !== Minus)
69
98
  return false;
@@ -73,12 +102,12 @@ export const noNegativeArrayIndex = createCompatRule({
73
102
  if (operand?.type !== 'Word')
74
103
  return false;
75
104
  const digits = sourceCode.resolveWordText(operand) ?? '';
76
- return /^\d+$/u.test(digits) && /[1-9]/u.test(digits);
105
+ return isNonzeroInteger(digits);
77
106
  }
78
107
  return {
79
108
  CallExpr(node) {
80
109
  const invoked = sourceCode.resolveInvokedCommand(node, {
81
- transparentWrappers: TRANSPARENT_WRAPPERS,
110
+ transparentWrappers,
82
111
  });
83
112
  if (invoked?.effective.static !== true || invoked.effective.text !== 'unset')
84
113
  return;
@@ -89,7 +118,8 @@ export const noNegativeArrayIndex = createCompatRule({
89
118
  const text = sourceCode.resolveWordText(word) ?? sourceCode.getText(word);
90
119
  if (text === '--')
91
120
  continue;
92
- if (UNSET_NEGATIVE.test(text) && /[1-9]/u.test(text)) {
121
+ const match = UNSET_NEGATIVE.exec(text);
122
+ if (match !== null && isNonzeroInteger(match[1])) {
93
123
  context.report({ node: word, messageId: 'negativeIndex', data: { ...gate } });
94
124
  }
95
125
  }
@@ -24,8 +24,8 @@
24
24
  * - whether the file turns `nounset` on anywhere: a `set` command that
25
25
  * enables it (`set -u`, `set -eu`, `set -o nounset`), or a shebang that
26
26
  * passes the flag (`#!/bin/bash -eu`). Where in the file that happens is
27
- * not considered, since a function defined before `set -u` can run after
28
- * it;
27
+ * not itself a proof of execution order: a function defined before
28
+ * `set -u` can run after it;
29
29
  * - every `${name[@]}` and `${name[*]}` with no operator, except inside
30
30
  * `${name[@]+…}` or `${name[@]:+…}` of the same array (the guard this rule
31
31
  * suggests), whose word Bash expands only when the array is set. `${#a[@]}`,
@@ -34,9 +34,17 @@
34
34
  * replacement `${a[@]/x/y}` were observed to work on Bash 3.2 to 4.1 and
35
35
  * fail only on 4.2 and 4.3, which a minimum-version gate cannot express, so
36
36
  * they are not reported either.
37
+ *
38
+ * Local safety proofs suppress candidates when a preceding static `set +u`,
39
+ * literal non-empty array assignment, or length guard establishes safety in
40
+ * the same statement list. Calls and compound statements invalidate facts;
41
+ * dynamic environment changes and variable attributes limit these proofs.
42
+ * `nounset-array-proof.ts` owns those boundaries. Reports remain conditional:
43
+ * unknown state is a reason to report, not proof that the array is empty.
37
44
  */
38
45
  import { AlternateUnset, AlternateUnsetOrNull } from 'sh-ast';
39
46
  import { createCompatRule } from './create-compat-rule.js';
47
+ import { createArraySafetyProof } from './nounset-array-proof.js';
40
48
  import { parseSetArguments, shebangOptionChanges } from '../set-options.js';
41
49
  /** A shell variable name. */
42
50
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
@@ -68,7 +76,7 @@ export const noNounsetEmptyArray = createCompatRule({
68
76
  description: 'Disallow whole-array expansions under `set -u` when the target shell aborts on an empty array',
69
77
  },
70
78
  messages: {
71
- emptyArray: 'If this array is empty, `${{{name}}[{{subscript}}]}` under `set -u` needs {{required}}, but this file targets {{target}}, where it aborts with "unbound variable". Guard it, for example `{{example}}`.',
79
+ emptyArray: 'This expansion is not proven safe. If this array is empty, `${{{name}}[{{subscript}}]}` under `set -u` needs {{required}}, but this file must support {{target}}, where it aborts with "unbound variable". Guard it, for example `{{example}}`.',
72
80
  guardExpansion: 'Replace with `{{replacement}}`.',
73
81
  },
74
82
  },
@@ -132,9 +140,12 @@ export const noNounsetEmptyArray = createCompatRule({
132
140
  candidates.push({ node, name, subscript });
133
141
  },
134
142
  'File:exit'() {
135
- if (!nounset)
143
+ if (!nounset || candidates.length === 0)
136
144
  return;
145
+ const provenSafe = createArraySafetyProof(sourceCode);
137
146
  for (const { node, name, subscript } of candidates) {
147
+ if (provenSafe(node, name))
148
+ continue;
138
149
  const replacement = guardedExpansion(name, subscript);
139
150
  const quoted = wholeQuotedWord(node);
140
151
  context.report({
@@ -56,8 +56,8 @@ export const noParameterTransformation = createMultiFeatureCompatRule({
56
56
  description: 'Disallow parameter transformations (`${v@Q}`, `${v@U}` and the others) when the target shell lacks the operator',
57
57
  },
58
58
  messages: {
59
- transformation: '`${…@{{operator}}}` needs {{required}}, but this file targets {{target}}. Use {{alternative}} instead.',
60
- transformationNoAlternative: '`${…@{{operator}}}` needs {{required}}, but this file targets {{target}}, which has no equivalent expansion; restructure the code that needs it.',
59
+ transformation: '`${…@{{operator}}}` needs {{required}}, but this file must support {{target}}. Use {{alternative}} instead.',
60
+ transformationNoAlternative: '`${…@{{operator}}}` needs {{required}}, but this file must support {{target}}, which has no equivalent expansion; restructure the code that needs it.',
61
61
  },
62
62
  },
63
63
  create(context, gates) {
@@ -31,7 +31,7 @@ export const noPipeAll = createCompatRule({
31
31
  description: 'Disallow the `|&` pipe when the target shell lacks it',
32
32
  },
33
33
  messages: {
34
- pipeAll: '`|&` needs {{required}}, but this file targets {{target}}. Write `cmd 2>&1 | other` instead.',
34
+ pipeAll: '`|&` needs {{required}}, but this file must support {{target}}. Write `cmd 2>&1 | other` instead.',
35
35
  useStderrRedirect: 'Replace `|&` with `2>&1 |`.',
36
36
  },
37
37
  },
@@ -16,6 +16,7 @@
16
16
  * word.
17
17
  */
18
18
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
19
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
19
20
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
20
21
  import { createCompatRule } from './create-compat-rule.js';
21
22
  /**
@@ -65,15 +66,16 @@ export const noPrintfTimeFormat = createCompatRule({
65
66
  description: "Disallow `printf`'s `%(fmt)T` date format when the target shell lacks it",
66
67
  },
67
68
  messages: {
68
- printfTime: "`printf '%({{format}})T'` needs {{required}}, but this file targets {{target}}. For the current time, run `date '+{{format}}'` instead.",
69
+ printfTime: "`printf '%({{format}})T'` needs {{required}}, but this file must support {{target}}. For the current time, run `date '+{{format}}'` instead.",
69
70
  },
70
71
  },
71
72
  create(context, gate) {
72
73
  const { sourceCode } = context;
74
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
73
75
  return {
74
76
  CallExpr(node) {
75
77
  const invoked = sourceCode.resolveInvokedCommand(node, {
76
- transparentWrappers: TRANSPARENT_WRAPPERS,
78
+ transparentWrappers,
77
79
  });
78
80
  if (invoked?.effective.static !== true || invoked.effective.text !== 'printf')
79
81
  return;
@@ -81,9 +81,10 @@ export const noSource = createCompatRule({
81
81
  create(context, gate) {
82
82
  const { sourceCode } = context;
83
83
  const fileDefinesSource = definedFunctionNames(sourceCode).has(SOURCE);
84
+ const reachableWord = reachableCommandWord(context, SOURCE);
84
85
  return {
85
86
  CallExpr(node) {
86
- const word = reachableCommandWord(sourceCode, node, SOURCE);
87
+ const word = reachableWord(node);
87
88
  if (word === undefined)
88
89
  return;
89
90
  // Only a name written directly can run a function: a wrapper skips it.
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { TsVarSet } from 'sh-ast';
29
29
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
30
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
30
31
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
31
32
  import { createCompatRule } from './create-compat-rule.js';
32
33
  /**
@@ -214,11 +215,12 @@ export const noTestV = createCompatRule({
214
215
  description: 'Disallow the `-v name` test (`[[ -v x ]]`, `test -v x`) when the target shell lacks it',
215
216
  },
216
217
  messages: {
217
- testV: '`-v {{name}}` needs {{required}}, but this file targets {{target}}. Test whether the variable is set with `[ -n "${{{name}}+set}" ]` instead.',
218
+ testV: '`-v {{name}}` needs {{required}}, but this file must support {{target}}. Test whether the variable is set with `[ -n "${{{name}}+set}" ]` instead.',
218
219
  },
219
220
  },
220
221
  create(context, gate) {
221
222
  const { sourceCode } = context;
223
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
222
224
  /** The variable name shown in the message for `operand`. */
223
225
  function nameOf(operand) {
224
226
  const text = operand?.type === 'Word' ? sourceCode.resolveWordText(operand) : null;
@@ -251,7 +253,7 @@ export const noTestV = createCompatRule({
251
253
  },
252
254
  CallExpr(node) {
253
255
  const invoked = sourceCode.resolveInvokedCommand(node, {
254
- transparentWrappers: TRANSPARENT_WRAPPERS,
256
+ transparentWrappers,
255
257
  });
256
258
  if (invoked?.effective.static !== true)
257
259
  return;
@@ -85,8 +85,8 @@ export const noUnicodeEscape = createMultiFeatureCompatRule({
85
85
  description: "Disallow `\\u`/`\\U` escapes in `$'…'` strings when the target shell does not decode them to UTF-8",
86
86
  },
87
87
  messages: {
88
- unicodeEscape: "`{{escape}}` in `$'…'` needs {{required}}, but this file targets {{target}}, which leaves it as literal text. Write the UTF-8 bytes as octal escapes instead, for example `$'{{octal}}'`.",
89
- unicodeEscapeLatin1: "`{{escape}}` in `$'…'` needs {{required}}, but this file targets {{target}}, which writes U+0080 to U+00FF as a single Latin-1 byte, not UTF-8. Write the UTF-8 bytes as octal escapes instead, for example `$'{{octal}}'`.",
88
+ unicodeEscape: "`{{escape}}` in `$'…'` needs {{required}}, but this file must support {{target}}, which leaves it as literal text. Write the UTF-8 bytes as octal escapes instead, for example `$'{{octal}}'`.",
89
+ unicodeEscapeLatin1: "`{{escape}}` in `$'…'` needs {{required}}, but this file must support {{target}}, which writes U+0080 to U+00FF as a single Latin-1 byte, not UTF-8. Write the UTF-8 bytes as octal escapes instead, for example `$'{{octal}}'`.",
90
90
  },
91
91
  },
92
92
  create(context, gates) {
@@ -16,6 +16,7 @@
16
16
  * the option word.
17
17
  */
18
18
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
19
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
19
20
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
20
21
  import { createCompatRule } from './create-compat-rule.js';
21
22
  /**
@@ -42,15 +43,16 @@ export const noWaitN = createCompatRule({
42
43
  description: 'Disallow `wait -n` when the target shell lacks it',
43
44
  },
44
45
  messages: {
45
- waitNext: '`wait -n` needs {{required}}, but this file targets {{target}}. Wait for one job at a time instead: keep each job\'s `$!` and run `wait "$pid"`, which returns that job\'s exit status.',
46
+ waitNext: '`wait -n` needs {{required}}, but this file must support {{target}}. Wait for one job at a time instead: keep each job\'s `$!` and run `wait "$pid"`, which returns that job\'s exit status.',
46
47
  },
47
48
  },
48
49
  create(context, gate) {
49
50
  const { sourceCode } = context;
51
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
50
52
  return {
51
53
  CallExpr(node) {
52
54
  const invoked = sourceCode.resolveInvokedCommand(node, {
53
- transparentWrappers: TRANSPARENT_WRAPPERS,
55
+ transparentWrappers,
54
56
  });
55
57
  if (invoked?.effective.static !== true || invoked.effective.text !== 'wait')
56
58
  return;
@@ -0,0 +1,8 @@
1
+ import type { ShellSourceCode } from '../../language/shell-source-code.js';
2
+ import type { ShellNodeOf } from '../../language/syntax-view.js';
3
+ /**
4
+ * Binds a proof query to one parsed file. The rule retains responsibility for
5
+ * selecting expansions and version gating; this helper only answers safety.
6
+ * @internal
7
+ */
8
+ export declare function createArraySafetyProof(source: ShellSourceCode): (node: ShellNodeOf<'ParamExp'>, name: string) => boolean;