@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
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Local proofs for the empty-array nounset gate. Facts belong to one sequential
3
+ * statement list: a successful literal assignment, a static option change, or
4
+ * a length test whose empty path exits. Calls and compound statements discard
5
+ * facts instead of guessing their effects. Function/loop/branch bodies start
6
+ * afresh; only a positive length condition supplies a fact to an if's then arm.
7
+ *
8
+ * This is deliberately not a shell interpreter or interprocedural analysis.
9
+ * Dynamic code, traps and redefined proof builtins disable proofs for the file;
10
+ * readonly, integer and reference attributes disable local proofs, since an
11
+ * apparent assignment may fail, write through another name, or evaluate arithmetic. Unknown means
12
+ * report, never assume safety from source order across execution boundaries.
13
+ */
14
+ import { AndStmt, AssignUnset, AssignUnsetOrNull, TsEql, TsGeq, TsGtr, TsLeq, TsLss, TsNeq, TsVarSet, } from 'sh-ast';
15
+ import { walkShell } from '../../language/syntax-view.js';
16
+ import { parseSetArguments } from '../set-options.js';
17
+ import { hasSpecialBashIntegerSetter } from '../bash-variable-semantics.js';
18
+ /** No local evidence is available on an unexamined execution path. */
19
+ const UNKNOWN = { nounsetOff: false, nonEmpty: new Set() };
20
+ /** These commands can install effects that outlive a later simple assignment. */
21
+ const DYNAMIC_ENVIRONMENT = new Set([
22
+ 'eval',
23
+ 'source',
24
+ '.',
25
+ 'trap',
26
+ 'alias',
27
+ 'unalias',
28
+ 'enable',
29
+ 'fc',
30
+ 'read',
31
+ 'mapfile',
32
+ 'readarray',
33
+ 'getopts',
34
+ 'unset',
35
+ 'let',
36
+ ]);
37
+ /** Length/exit/option proofs require these names to retain their builtin meaning. */
38
+ const PROOF_BUILTINS = new Set([
39
+ 'set',
40
+ '[',
41
+ 'test',
42
+ 'return',
43
+ 'exit',
44
+ 'builtin',
45
+ 'command',
46
+ 'printf',
47
+ ]);
48
+ /** Numeric [[ tests recursively evaluate their operands as arithmetic. */
49
+ const NUMERIC_TESTS = new Set([
50
+ TsEql,
51
+ TsNeq,
52
+ TsGtr,
53
+ TsGeq,
54
+ TsLss,
55
+ TsLeq,
56
+ ]);
57
+ /** Only these simple string conversions are known not to write printf's %n destination. */
58
+ const INERT_PRINTF_FORMAT = /^(?:[^%]|%%|%[sbqc])*$/u;
59
+ /** Fixed unary tests inspect their operand as data, without looking up a variable by name. */
60
+ const INERT_TEST_UNARY = new Set([
61
+ '!',
62
+ '-n',
63
+ '-z',
64
+ '-a',
65
+ '-b',
66
+ '-c',
67
+ '-d',
68
+ '-e',
69
+ '-f',
70
+ '-g',
71
+ '-h',
72
+ '-k',
73
+ '-p',
74
+ '-r',
75
+ '-s',
76
+ '-t',
77
+ '-u',
78
+ '-w',
79
+ '-x',
80
+ '-G',
81
+ '-L',
82
+ '-N',
83
+ '-O',
84
+ '-S',
85
+ ]);
86
+ /** Command-based numeric tests parse integers, unlike [[ arithmetic evaluation. */
87
+ const INERT_TEST_BINARY = new Set([
88
+ '=',
89
+ '==',
90
+ '!=',
91
+ '<',
92
+ '>',
93
+ '-eq',
94
+ '-ne',
95
+ '-gt',
96
+ '-ge',
97
+ '-lt',
98
+ '-le',
99
+ '-nt',
100
+ '-ot',
101
+ '-ef',
102
+ ]);
103
+ /** Names accepted for an ordinary array, excluding arbitrary parameter syntax. */
104
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
105
+ /** These variables hold interpreter state or a trace prefix whose expansions can change that state. */
106
+ function interpreterVariable(name) {
107
+ return (name !== undefined &&
108
+ (name.startsWith('BASH_') || name === 'PS4' || hasSpecialBashIntegerSetter(name)));
109
+ }
110
+ /** Redirections can fail before an assignment/builtin; background work need not finish. */
111
+ function sequential(stmt) {
112
+ return (!stmt.background && !stmt.coprocess && !stmt.disown && !stmt.negated && stmt.redirs.length === 0);
113
+ }
114
+ /** A subscript that cannot evaluate arithmetic, indirection or another command. */
115
+ function wholeArrayIndex(index) {
116
+ return (index?.type === 'Word' &&
117
+ index.parts.length === 1 &&
118
+ index.parts[0].type === 'Lit' &&
119
+ ['@', '*'].includes(index.parts[0].value));
120
+ }
121
+ /** Words eligible for a proof cannot mutate state while their arguments expand. */
122
+ function inert(node) {
123
+ switch (node.type) {
124
+ case 'Word':
125
+ return node.parts.every(inert);
126
+ case 'DblQuoted':
127
+ return !node.dollar && node.parts.every(inert);
128
+ case 'Lit':
129
+ case 'SglQuoted':
130
+ return true;
131
+ case 'ParamExp':
132
+ return (node.exp === undefined &&
133
+ node.slice === undefined &&
134
+ node.repl === undefined &&
135
+ !node.excl &&
136
+ !node.width &&
137
+ !node.isset &&
138
+ node.modifiers.length === 0 &&
139
+ (node.index === undefined || wholeArrayIndex(node.index)));
140
+ default:
141
+ return false;
142
+ }
143
+ }
144
+ /**
145
+ * Binds a proof query to one parsed file. The rule retains responsibility for
146
+ * selecting expansions and version gating; this helper only answers safety.
147
+ * @internal
148
+ */
149
+ export function createArraySafetyProof(source) {
150
+ let dynamicEnvironment = false;
151
+ let variableAttributes = false;
152
+ /** One snapshot per statement; the first lookup fills the entire sibling list. */
153
+ // eslint-disable-next-line @api-extractor-tools/forgotten-export -- Cached entry facts are closure-private and are not exposed by the exported query.
154
+ const before = new WeakMap();
155
+ walkShell(source.ast, (node) => {
156
+ if (node.type === 'FuncDecl' &&
157
+ (!IDENTIFIER.test(node.name?.value ?? '') || PROOF_BUILTINS.has(node.name?.value ?? ''))) {
158
+ dynamicEnvironment = true;
159
+ }
160
+ if (node.type === 'CallExpr' && node.args.length > 0) {
161
+ const args = node.args.map((word) => source.resolveWordText(word));
162
+ let index = 0;
163
+ // Wrappers do not make dynamic code or attribute writes transparent.
164
+ while (args[index] === 'builtin' || args[index] === 'command') {
165
+ index += 1;
166
+ while (args[index]?.startsWith('-')) {
167
+ const option = args[index];
168
+ if (option !== '--' && option !== '-p') {
169
+ dynamicEnvironment = true;
170
+ }
171
+ index += 1;
172
+ if (option === '--') {
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ const name = args.at(index);
178
+ if (name === null || name === undefined || DYNAMIC_ENVIRONMENT.has(name)) {
179
+ dynamicEnvironment = true;
180
+ }
181
+ if ((name === '[' || name === 'test') && !inertTest(name, node.args.slice(index + 1))) {
182
+ dynamicEnvironment = true;
183
+ }
184
+ if (name === 'printf' && !inertPrintf(args.slice(index + 1))) {
185
+ dynamicEnvironment = true;
186
+ }
187
+ if (name !== null &&
188
+ name !== undefined &&
189
+ ['declare', 'local', 'typeset', 'readonly', 'nameref', 'export'].includes(name)) {
190
+ variableAttributes = true;
191
+ }
192
+ }
193
+ // for/select bind their variable without an Assign node; special setters still run.
194
+ if (node.type === 'WordIter' &&
195
+ (node.name === undefined ||
196
+ !IDENTIFIER.test(node.name.value) ||
197
+ interpreterVariable(node.name.value))) {
198
+ dynamicEnvironment = true;
199
+ }
200
+ // Special Bash arrays can install aliases or other interpreter state.
201
+ if (node.type === 'Assign' && interpreterVariable(node.name?.value)) {
202
+ dynamicEnvironment = true;
203
+ }
204
+ if (node.type === 'ParamExp' &&
205
+ interpreterVariable(node.param?.value) &&
206
+ (node.exp?.op === AssignUnset || node.exp?.op === AssignUnsetOrNull)) {
207
+ dynamicEnvironment = true;
208
+ }
209
+ if (node.type === 'DeclClause') {
210
+ const attributes = node.variant?.value !== 'export';
211
+ variableAttributes ||=
212
+ ['readonly', 'nameref'].includes(node.variant?.value ?? '') ||
213
+ node.args.some((arg) => {
214
+ if (!arg.naked) {
215
+ return false;
216
+ }
217
+ if (arg.name !== undefined) {
218
+ return !IDENTIFIER.test(arg.name.value);
219
+ }
220
+ const text = arg.value === undefined ? null : source.resolveWordText(arg.value);
221
+ // A naked declaration word can introduce options or an assignment at runtime.
222
+ return (text === null ||
223
+ (text !== '--' && !/^[-+][A-Za-z]+$/u.test(text)) ||
224
+ (attributes && /^-[^-]*[rniA]/u.test(text)));
225
+ });
226
+ }
227
+ // Arithmetic operands can recursively evaluate variable text and write interpreter arrays.
228
+ if ((node.type === 'ArithmCmd' || node.type === 'ArithmExp') && !simpleArithmetic(node.x)) {
229
+ dynamicEnvironment = true;
230
+ }
231
+ if (node.type === 'BinaryArithm' ||
232
+ node.type === 'UnaryArithm' ||
233
+ node.type === 'LetClause' ||
234
+ node.type === 'CStyleLoop' ||
235
+ node.type === 'CoprocClause') {
236
+ dynamicEnvironment = true;
237
+ }
238
+ if ((node.type === 'Assign' || node.type === 'ArrayElem') &&
239
+ node.index !== undefined &&
240
+ !simpleArithmetic(node.index)) {
241
+ dynamicEnvironment = true;
242
+ }
243
+ if (node.type === 'ParamExp' &&
244
+ (node.excl ||
245
+ node.slice !== undefined ||
246
+ (node.index !== undefined && !wholeArrayIndex(node.index) && !simpleArithmetic(node.index)))) {
247
+ dynamicEnvironment = true;
248
+ }
249
+ if (node.type === 'BinaryTest' &&
250
+ NUMERIC_TESTS.has(node.op) &&
251
+ (!simpleArithmetic(node.x) || !simpleArithmetic(node.y))) {
252
+ dynamicEnvironment = true;
253
+ }
254
+ // Translated text is expanded by the shell, beyond the syntax visible here.
255
+ if (node.type === 'DblQuoted' && node.dollar) {
256
+ dynamicEnvironment = true;
257
+ }
258
+ if (node.type === 'UnaryTest' && node.op === TsVarSet) {
259
+ dynamicEnvironment = true;
260
+ }
261
+ });
262
+ /** The named array's length as a single quoted or unquoted word. */
263
+ function lengthName(word, quotedOnly = false) {
264
+ if (word?.type !== 'Word' || word.parts.length !== 1) {
265
+ return undefined;
266
+ }
267
+ const part = word.parts[0];
268
+ if (quotedOnly && (part.type !== 'DblQuoted' || part.dollar)) {
269
+ return undefined;
270
+ }
271
+ const exp = part.type === 'DblQuoted' && part.parts.length === 1 ? part.parts[0] : part;
272
+ if (exp.type !== 'ParamExp' || !exp.length || !inert(exp) || !wholeArrayIndex(exp.index)) {
273
+ return undefined;
274
+ }
275
+ const name = exp.param?.value;
276
+ return name !== undefined && IDENTIFIER.test(name) ? name : undefined;
277
+ }
278
+ /** Literal integers and direct array lengths cannot recursively evaluate variable text. */
279
+ function simpleArithmetic(node) {
280
+ return (lengthName(node) !== undefined ||
281
+ (node?.type === 'Word' && /^\d+$/u.test(source.resolveWordText(node) ?? '')));
282
+ }
283
+ /** Only ordinary destinations and known string formats rule out persistent printf writes. */
284
+ function inertPrintf(args) {
285
+ let index = 0;
286
+ if (args[0] === '-v') {
287
+ const destination = args.at(1);
288
+ if (destination === null ||
289
+ destination === undefined ||
290
+ !IDENTIFIER.test(destination) ||
291
+ interpreterVariable(destination) ||
292
+ destination === 'FUNCNAME') {
293
+ return false;
294
+ }
295
+ index = 2;
296
+ }
297
+ const ended = args[index] === '--';
298
+ if (ended) {
299
+ index += 1;
300
+ }
301
+ const format = args.at(index);
302
+ return (format !== null &&
303
+ format !== undefined &&
304
+ (ended || !format.startsWith('-')) &&
305
+ INERT_PRINTF_FORMAT.test(format));
306
+ }
307
+ /** A quoted scalar is one test operand; array/positional @ expansions can inject operators. */
308
+ function singleTestArgument(word) {
309
+ if (source.resolveWordText(word) !== null) {
310
+ return true;
311
+ }
312
+ if (word.parts.length !== 1 || word.parts[0].type !== 'DblQuoted' || word.parts[0].dollar) {
313
+ return false;
314
+ }
315
+ let single = true;
316
+ walkShell(word.parts[0], (part) => {
317
+ if (part.type === 'ParamExp' &&
318
+ !part.length &&
319
+ (part.param?.value === '@' ||
320
+ (part.index !== undefined &&
321
+ wholeArrayIndex(part.index) &&
322
+ source.getText(part.index) === '@'))) {
323
+ single = false;
324
+ }
325
+ });
326
+ return single;
327
+ }
328
+ /** Fixed test operators plus one-word operands exclude hidden -v/-R variable-name evaluation. */
329
+ function inertTest(name, words) {
330
+ const args = [...words];
331
+ if (name === '[') {
332
+ const close = args.at(-1);
333
+ if (close === undefined || source.resolveWordText(close) !== ']') {
334
+ return false;
335
+ }
336
+ args.pop();
337
+ }
338
+ if (!args.every(singleTestArgument)) {
339
+ return false;
340
+ }
341
+ if (args.length < 2) {
342
+ return true;
343
+ }
344
+ if (args.length === 2) {
345
+ return INERT_TEST_UNARY.has(source.resolveWordText(args[0]) ?? '');
346
+ }
347
+ return args.length === 3 && INERT_TEST_BINARY.has(source.resolveWordText(args[1]) ?? '');
348
+ }
349
+ /** Exact zero/nonzero length predicates, with no other commands or arithmetic. */
350
+ function testedArray(stmt, empty) {
351
+ if (!sequential(stmt)) {
352
+ return undefined;
353
+ }
354
+ const cmd = stmt.cmd;
355
+ if (cmd?.type === 'CallExpr' && cmd.assigns.length === 0) {
356
+ const name = source.getCommandName(cmd);
357
+ const args = cmd.args.slice(1);
358
+ if (name === '[' &&
359
+ args.length > 0 &&
360
+ source.resolveWordText(args[args.length - 1]) === ']') {
361
+ args.pop();
362
+ }
363
+ else if (name !== 'test') {
364
+ return undefined;
365
+ }
366
+ if (args.length !== 3 || source.resolveWordText(args[2]) !== '0') {
367
+ return undefined;
368
+ }
369
+ const op = source.resolveWordText(args[1]);
370
+ if (empty ? op !== '-eq' : op !== '-gt' && op !== '-ne') {
371
+ return undefined;
372
+ }
373
+ return lengthName(args[0], true);
374
+ }
375
+ if (cmd?.type === 'TestClause' && cmd.x?.type === 'BinaryTest') {
376
+ const { op, x, y } = cmd.x;
377
+ if (y?.type !== 'Word' || source.resolveWordText(y) !== '0') {
378
+ return undefined;
379
+ }
380
+ if (empty ? op !== TsEql : op !== TsGtr && op !== TsNeq) {
381
+ return undefined;
382
+ }
383
+ return lengthName(x);
384
+ }
385
+ if (!empty && cmd?.type === 'ArithmCmd') {
386
+ return lengthName(cmd.x);
387
+ }
388
+ return undefined;
389
+ }
390
+ /** A return exits only a function here; a standalone file might not be sourced. */
391
+ function exits(stmt) {
392
+ if (!sequential(stmt) || stmt.cmd?.type !== 'CallExpr' || stmt.cmd.assigns.length > 0) {
393
+ return false;
394
+ }
395
+ const cmd = stmt.cmd;
396
+ const name = source.getCommandName(cmd);
397
+ if (name !== 'exit' && name !== 'return') {
398
+ return false;
399
+ }
400
+ if (cmd.args.length > 2) {
401
+ return false;
402
+ }
403
+ if (cmd.args.length === 2 && !/^\d{1,3}$/u.test(source.resolveWordText(cmd.args[1]) ?? '')) {
404
+ return false;
405
+ }
406
+ if (name === 'exit') {
407
+ return true;
408
+ }
409
+ for (let up = source.getParent(stmt); up !== undefined; up = source.getParent(up)) {
410
+ if (up.type === 'FuncDecl') {
411
+ return true;
412
+ }
413
+ }
414
+ return false;
415
+ }
416
+ /**
417
+ * Literal members cannot disappear through splitting or nullglob. Quoted empty
418
+ * strings count as members; an unquoted pattern or a computed word does not.
419
+ */
420
+ function literalArray(assign) {
421
+ return (!variableAttributes &&
422
+ assign.name?.value !== 'FUNCNAME' &&
423
+ !assign.append &&
424
+ assign.index === undefined &&
425
+ assign.array !== undefined &&
426
+ assign.array.elems.length > 0 &&
427
+ assign.array.elems.every((elem) => elem.index === undefined &&
428
+ elem.value !== undefined &&
429
+ source.resolveWordText(elem.value) !== null));
430
+ }
431
+ /** Ordinary assignments preserve other names only when no expansion can write indirectly. */
432
+ function inertAssignment(assign) {
433
+ return (assign.index === undefined &&
434
+ !assign.naked &&
435
+ (assign.value === undefined || inert(assign.value)) &&
436
+ (assign.array === undefined ||
437
+ assign.array.elems.every((elem) => elem.index === undefined && elem.value !== undefined && inert(elem.value))));
438
+ }
439
+ /** Transfer only the few operations whose successful effect is known locally. */
440
+ function after(stmt, facts) {
441
+ if (!sequential(stmt)) {
442
+ return UNKNOWN;
443
+ }
444
+ const cmd = stmt.cmd;
445
+ if (cmd?.type === 'BinaryCmd' &&
446
+ cmd.op === AndStmt &&
447
+ cmd.x !== undefined &&
448
+ cmd.y !== undefined &&
449
+ exits(cmd.y)) {
450
+ const name = testedArray(cmd.x, true);
451
+ if (name !== undefined && !variableAttributes) {
452
+ return { nounsetOff: facts.nounsetOff, nonEmpty: new Set([...facts.nonEmpty, name]) };
453
+ }
454
+ }
455
+ if (cmd?.type !== 'CallExpr') {
456
+ return UNKNOWN;
457
+ }
458
+ if (cmd.args.length === 0 && cmd.assigns.every(inertAssignment)) {
459
+ const nonEmpty = new Set(facts.nonEmpty);
460
+ for (const assign of cmd.assigns) {
461
+ const name = assign.name?.value;
462
+ if (name === undefined) {
463
+ return UNKNOWN;
464
+ }
465
+ nonEmpty.delete(name);
466
+ if (literalArray(assign)) {
467
+ nonEmpty.add(name);
468
+ }
469
+ }
470
+ return { nounsetOff: facts.nounsetOff, nonEmpty };
471
+ }
472
+ if (source.getCommandName(cmd) === 'set' && cmd.assigns.length === 0) {
473
+ const parsed = parseSetArguments(cmd.args.slice(1).map((arg) => source.resolveWordText(arg)));
474
+ if (parsed.stoppedBy === 'unknown' || parsed.stoppedBy === 'malformed') {
475
+ return UNKNOWN;
476
+ }
477
+ let nounsetOff = facts.nounsetOff;
478
+ for (const change of parsed.changes) {
479
+ if (change.option === 'nounset') {
480
+ nounsetOff = !change.enable;
481
+ }
482
+ }
483
+ return { nounsetOff, nonEmpty: facts.nonEmpty };
484
+ }
485
+ return UNKNOWN;
486
+ }
487
+ /** A list is a proof boundary, rather than evidence that neighboring lists ran. */
488
+ function siblings(stmt) {
489
+ const parent = source.getParent(stmt);
490
+ switch (parent?.type) {
491
+ case 'File':
492
+ case 'Block':
493
+ case 'Subshell':
494
+ case 'CaseItem':
495
+ return parent.stmts;
496
+ case 'IfClause':
497
+ return parent.then.includes(stmt) ? parent.then : parent.cond;
498
+ case 'ForClause':
499
+ return parent.do;
500
+ case 'WhileClause':
501
+ return parent.do.includes(stmt) ? parent.do : parent.cond;
502
+ default:
503
+ return [];
504
+ }
505
+ }
506
+ /** Entry facts never cross functions or loops; an if condition can prove its then arm. */
507
+ function entry(stmt) {
508
+ const cached = before.get(stmt);
509
+ if (cached !== undefined) {
510
+ return cached;
511
+ }
512
+ const parent = source.getParent(stmt);
513
+ // The left side of &&/|| runs first; no evidence flows to their right side.
514
+ if (parent?.type === 'BinaryCmd' && parent.x === stmt) {
515
+ const outer = source.getParent(parent);
516
+ return outer?.type === 'Stmt' ? entry(outer) : UNKNOWN;
517
+ }
518
+ let facts = UNKNOWN;
519
+ if (!variableAttributes &&
520
+ parent?.type === 'IfClause' &&
521
+ parent.then.includes(stmt) &&
522
+ parent.cond.length === 1) {
523
+ const name = testedArray(parent.cond[0], false);
524
+ if (name !== undefined) {
525
+ facts = { nounsetOff: false, nonEmpty: new Set([name]) };
526
+ }
527
+ }
528
+ for (const sibling of siblings(stmt)) {
529
+ before.set(sibling, facts);
530
+ facts = after(sibling, facts);
531
+ }
532
+ return before.get(stmt) ?? UNKNOWN;
533
+ }
534
+ return (node, name) => {
535
+ if (dynamicEnvironment || variableAttributes) {
536
+ return false;
537
+ }
538
+ let stmt = source.getParent(node);
539
+ while (stmt !== undefined && stmt.type !== 'Stmt') {
540
+ stmt = source.getParent(stmt);
541
+ }
542
+ if (stmt?.type !== 'Stmt' || !sequential(stmt)) {
543
+ return false;
544
+ }
545
+ const cmd = stmt.cmd;
546
+ if (cmd?.type === 'CallExpr') {
547
+ if (cmd.assigns.length > 0 || !cmd.args.every(inert)) {
548
+ return false;
549
+ }
550
+ }
551
+ else if (cmd?.type === 'ForClause' && cmd.loop?.type === 'WordIter') {
552
+ if (!cmd.loop.items.every(inert) ||
553
+ !cmd.loop.items.some((word) => word.range[0] <= node.range[0] && word.range[1] >= node.range[1])) {
554
+ return false;
555
+ }
556
+ }
557
+ else {
558
+ return false;
559
+ }
560
+ const facts = entry(stmt);
561
+ return facts.nounsetOff || facts.nonEmpty.has(name);
562
+ };
563
+ }
@@ -1,12 +1,11 @@
1
- import type { ShellSourceCode } from '../../language/shell-source-code.js';
1
+ import type { TransparentWrappersBindingContext } from '../resolve-transparent-wrappers.js';
2
2
  import type { ShellNode, ShellNodeOf } from '../../language/syntax-view.js';
3
3
  /**
4
- * The word that names `command` in `node`, when `command` is what `node`
5
- * effectively runs and a shell without it can reach `node` (see the module
6
- * comment), or `undefined` otherwise. Reporting this word rather than the whole
7
- * command matters for `[[`: the parser ends that command at the first `&&` or
8
- * `||`, so the node would cover an arbitrary part of the expression.
9
- *
4
+ * Builds a lookup for the word that names `command` when an invocation runs it
5
+ * and the target shell can reach it outside a shell-version guard. Resolve
6
+ * the wrapper table once for the file, rather than once per invocation.
7
+ * Reporting this word matters for `[[`: the parser can end that command at
8
+ * the first boolean operator, so the whole node is not the expression.
10
9
  * @internal
11
10
  */
12
- export declare function reachableCommandWord(sourceCode: ShellSourceCode, node: ShellNodeOf<'CallExpr'>, command: string): ShellNode | undefined;
11
+ export declare function reachableCommandWord(context: TransparentWrappersBindingContext, command: string): (node: ShellNodeOf<'CallExpr'>) => ShellNode | undefined;
@@ -23,6 +23,7 @@
23
23
  * reports.
24
24
  */
25
25
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
26
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
26
27
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
27
28
  /**
28
29
  * The wrappers followed to find the command an invocation runs:
@@ -114,21 +115,22 @@ function isGuarded(sourceCode, node) {
114
115
  return false;
115
116
  }
116
117
  /**
117
- * The word that names `command` in `node`, when `command` is what `node`
118
- * effectively runs and a shell without it can reach `node` (see the module
119
- * comment), or `undefined` otherwise. Reporting this word rather than the whole
120
- * command matters for `[[`: the parser ends that command at the first `&&` or
121
- * `||`, so the node would cover an arbitrary part of the expression.
122
- *
118
+ * Builds a lookup for the word that names `command` when an invocation runs it
119
+ * and the target shell can reach it outside a shell-version guard. Resolve
120
+ * the wrapper table once for the file, rather than once per invocation.
121
+ * Reporting this word matters for `[[`: the parser can end that command at
122
+ * the first boolean operator, so the whole node is not the expression.
123
123
  * @internal
124
124
  */
125
- export function reachableCommandWord(sourceCode, node, command) {
126
- const invoked = sourceCode.resolveInvokedCommand(node, {
127
- transparentWrappers: TRANSPARENT_WRAPPERS,
128
- });
129
- if (invoked?.effective.static !== true || invoked.effective.text !== command)
130
- return undefined;
131
- if (isGuarded(sourceCode, node))
132
- return undefined;
133
- return node.args[invoked.chainIndices.at(-1) ?? 0];
125
+ export function reachableCommandWord(context, command) {
126
+ const { sourceCode } = context;
127
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
128
+ return (node) => {
129
+ const invoked = sourceCode.resolveInvokedCommand(node, { transparentWrappers });
130
+ if (invoked?.effective.static !== true || invoked.effective.text !== command)
131
+ return undefined;
132
+ if (isGuarded(sourceCode, node))
133
+ return undefined;
134
+ return node.args[invoked.chainIndices.at(-1) ?? 0];
135
+ };
134
136
  }
@@ -10,6 +10,7 @@
10
10
  * static word cannot be read, so none of those count.
11
11
  */
12
12
  import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
13
+ import { resolveTransparentWrappers } from '../resolve-transparent-wrappers.js';
13
14
  import { BUILTIN_WRAPPER } from '../transparent-wrappers.js';
14
15
  /**
15
16
  * The wrappers followed to find the `shopt` command: `sh-ast/analyze`'s
@@ -27,10 +28,11 @@ const TRANSPARENT_WRAPPERS = [
27
28
  */
28
29
  export function shoptEnableVisitor(context, option, report) {
29
30
  const { sourceCode } = context;
31
+ const transparentWrappers = resolveTransparentWrappers(context, TRANSPARENT_WRAPPERS);
30
32
  return {
31
33
  CallExpr(node) {
32
34
  const invoked = sourceCode.resolveInvokedCommand(node, {
33
- transparentWrappers: TRANSPARENT_WRAPPERS,
35
+ transparentWrappers,
34
36
  });
35
37
  if (invoked?.effective.static !== true || invoked.effective.text !== 'shopt')
36
38
  return;