@eslint-sh/eslint-plugin 0.5.0 → 0.7.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 (260) hide show
  1. package/README.md +228 -17
  2. package/dist/command-harvest/builtins/bash.d.ts +36 -0
  3. package/dist/command-harvest/builtins/bash.js +98 -0
  4. package/dist/command-harvest/builtins/bats.d.ts +45 -0
  5. package/dist/command-harvest/builtins/bats.js +33 -0
  6. package/dist/command-harvest/builtins/index.d.ts +18 -0
  7. package/dist/command-harvest/builtins/index.js +39 -0
  8. package/dist/command-harvest/builtins/posix.d.ts +51 -0
  9. package/dist/command-harvest/builtins/posix.js +93 -0
  10. package/dist/command-harvest/harvest-commands.d.ts +66 -0
  11. package/dist/command-harvest/harvest-commands.js +97 -0
  12. package/dist/command-harvest/wrapper-table.d.ts +24 -0
  13. package/dist/command-harvest/wrapper-table.js +49 -0
  14. package/dist/configs/agent-authored.d.ts +41 -0
  15. package/dist/configs/agent-authored.js +33 -0
  16. package/dist/configs/bash-3-2.d.ts +28 -0
  17. package/dist/configs/bash-3-2.js +51 -0
  18. package/dist/configs/posix-portable.d.ts +40 -0
  19. package/dist/configs/posix-portable.js +53 -0
  20. package/dist/configs/recommended.d.ts +52 -0
  21. package/dist/configs/recommended.js +34 -0
  22. package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
  23. package/dist/declaration-conventions/comment-pragma.js +153 -0
  24. package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
  25. package/dist/declaration-conventions/declaration-convention.js +1 -0
  26. package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
  27. package/dist/declaration-conventions/read-declared-commands.js +128 -0
  28. package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
  29. package/dist/eslint-plugin-syntax.d.ts +27 -0
  30. package/dist/eslint-plugin.d.ts +1211 -52
  31. package/dist/index.d.ts +56 -8
  32. package/dist/index.js +53 -6
  33. package/dist/language/command-resolution.d.ts +124 -0
  34. package/dist/language/command-resolution.js +120 -0
  35. package/dist/language/end-of-input-marker.d.ts +29 -0
  36. package/dist/language/end-of-input-marker.js +88 -0
  37. package/dist/language/line-breaks.d.ts +18 -0
  38. package/dist/language/line-breaks.js +22 -0
  39. package/dist/language/lint-view-types.d.ts +104 -0
  40. package/dist/language/lint-view-types.js +1 -0
  41. package/dist/language/non-shell-file-guard.d.ts +35 -0
  42. package/dist/language/non-shell-file-guard.js +59 -0
  43. package/dist/language/selector-classes.d.ts +0 -1
  44. package/dist/language/selector-classes.js +0 -1
  45. package/dist/language/shell-language-options.d.ts +7 -1
  46. package/dist/language/shell-language-options.js +4 -4
  47. package/dist/language/shell-language.d.ts +10 -2
  48. package/dist/language/shell-language.js +59 -8
  49. package/dist/language/shell-source-code.d.ts +342 -5
  50. package/dist/language/shell-source-code.js +559 -30
  51. package/dist/language/source-coordinates.d.ts +13 -2
  52. package/dist/language/source-coordinates.js +68 -14
  53. package/dist/language/syntax-view.d.ts +60 -39
  54. package/dist/language/syntax-view.js +200 -45
  55. package/dist/language/word-resolution.d.ts +4 -3
  56. package/dist/language/word-resolution.js +4 -3
  57. package/dist/rules/compat/create-compat-rule.d.ts +59 -0
  58. package/dist/rules/compat/create-compat-rule.js +65 -0
  59. package/dist/rules/compat/declaration-options.d.ts +19 -0
  60. package/dist/rules/compat/declaration-options.js +110 -0
  61. package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
  62. package/dist/rules/compat/no-append-all-redirect.js +73 -0
  63. package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
  64. package/dist/rules/compat/no-associative-arrays.js +39 -0
  65. package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
  66. package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
  67. package/dist/rules/compat/no-case-attributes.d.ts +12 -0
  68. package/dist/rules/compat/no-case-attributes.js +63 -0
  69. package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
  70. package/dist/rules/compat/no-case-fallthrough.js +67 -0
  71. package/dist/rules/compat/no-case-modification.d.ts +35 -0
  72. package/dist/rules/compat/no-case-modification.js +86 -0
  73. package/dist/rules/compat/no-coproc.d.ts +12 -0
  74. package/dist/rules/compat/no-coproc.js +47 -0
  75. package/dist/rules/compat/no-declare-g.d.ts +12 -0
  76. package/dist/rules/compat/no-declare-g.js +59 -0
  77. package/dist/rules/compat/no-double-bracket.d.ts +12 -0
  78. package/dist/rules/compat/no-double-bracket.js +52 -0
  79. package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
  80. package/dist/rules/compat/no-epoch-variables.js +57 -0
  81. package/dist/rules/compat/no-fd-variable.d.ts +12 -0
  82. package/dist/rules/compat/no-fd-variable.js +48 -0
  83. package/dist/rules/compat/no-globstar.d.ts +12 -0
  84. package/dist/rules/compat/no-globstar.js +39 -0
  85. package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
  86. package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
  87. package/dist/rules/compat/no-let.d.ts +12 -0
  88. package/dist/rules/compat/no-let.js +62 -0
  89. package/dist/rules/compat/no-mapfile.d.ts +13 -0
  90. package/dist/rules/compat/no-mapfile.js +122 -0
  91. package/dist/rules/compat/no-nameref.d.ts +12 -0
  92. package/dist/rules/compat/no-nameref.js +44 -0
  93. package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
  94. package/dist/rules/compat/no-negative-array-index.js +123 -0
  95. package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
  96. package/dist/rules/compat/no-nounset-empty-array.js +158 -0
  97. package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
  98. package/dist/rules/compat/no-parameter-transformation.js +123 -0
  99. package/dist/rules/compat/no-pipe-all.d.ts +13 -0
  100. package/dist/rules/compat/no-pipe-all.js +68 -0
  101. package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
  102. package/dist/rules/compat/no-printf-time-format.js +99 -0
  103. package/dist/rules/compat/no-source.d.ts +13 -0
  104. package/dist/rules/compat/no-source.js +103 -0
  105. package/dist/rules/compat/no-test-v.d.ts +12 -0
  106. package/dist/rules/compat/no-test-v.js +280 -0
  107. package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
  108. package/dist/rules/compat/no-unicode-escape.js +120 -0
  109. package/dist/rules/compat/no-wait-n.d.ts +12 -0
  110. package/dist/rules/compat/no-wait-n.js +73 -0
  111. package/dist/rules/compat/operator-range.d.ts +10 -0
  112. package/dist/rules/compat/operator-range.js +15 -0
  113. package/dist/rules/compat/posix-absent-command.d.ts +12 -0
  114. package/dist/rules/compat/posix-absent-command.js +134 -0
  115. package/dist/rules/compat/shopt-options.d.ts +9 -0
  116. package/dist/rules/compat/shopt-options.js +63 -0
  117. package/dist/rules/compat/version-gates.d.ts +76 -0
  118. package/dist/rules/compat/version-gates.js +205 -0
  119. package/dist/rules/create-rule.d.ts +91 -0
  120. package/dist/rules/create-rule.js +51 -0
  121. package/dist/rules/create-wrapper-rule.d.ts +166 -0
  122. package/dist/rules/create-wrapper-rule.js +407 -0
  123. package/dist/rules/define-shell-rule.d.ts +184 -0
  124. package/dist/rules/define-shell-rule.js +56 -0
  125. package/dist/rules/index.d.ts +26 -0
  126. package/dist/rules/index.js +90 -0
  127. package/dist/rules/max-script-complexity.d.ts +28 -0
  128. package/dist/rules/max-script-complexity.js +130 -0
  129. package/dist/rules/no-dynamic-invocation.d.ts +35 -0
  130. package/dist/rules/no-dynamic-invocation.js +117 -0
  131. package/dist/rules/no-eval.d.ts +13 -0
  132. package/dist/rules/no-eval.js +89 -0
  133. package/dist/rules/no-masked-exit-status.d.ts +21 -0
  134. package/dist/rules/no-masked-exit-status.js +667 -0
  135. package/dist/rules/no-secret-env-reads.d.ts +24 -0
  136. package/dist/rules/no-secret-env-reads.js +133 -0
  137. package/dist/rules/no-secret-flags.d.ts +32 -0
  138. package/dist/rules/no-secret-flags.js +162 -0
  139. package/dist/rules/no-unchecked-cd.d.ts +44 -0
  140. package/dist/rules/no-unchecked-cd.js +250 -0
  141. package/dist/rules/no-undeclared-commands.d.ts +20 -0
  142. package/dist/rules/no-undeclared-commands.js +115 -0
  143. package/dist/rules/no-unused-declarations.d.ts +23 -0
  144. package/dist/rules/no-unused-declarations.js +259 -0
  145. package/dist/rules/prefer-local.d.ts +16 -0
  146. package/dist/rules/prefer-local.js +249 -0
  147. package/dist/rules/quote-expansions.d.ts +13 -0
  148. package/dist/rules/quote-expansions.js +229 -0
  149. package/dist/rules/require-network-wrapper.d.ts +40 -0
  150. package/dist/rules/require-network-wrapper.js +244 -0
  151. package/dist/rules/require-shebang.d.ts +29 -0
  152. package/dist/rules/require-shebang.js +101 -0
  153. package/dist/rules/require-strict-mode.d.ts +52 -0
  154. package/dist/rules/require-strict-mode.js +177 -0
  155. package/dist/rules/require-tty-guard.d.ts +13 -0
  156. package/dist/rules/require-tty-guard.js +318 -0
  157. package/dist/rules/resolve-declaration-convention.d.ts +96 -0
  158. package/dist/rules/resolve-declaration-convention.js +109 -0
  159. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  160. package/dist/rules/resolve-network-wrapper.js +116 -0
  161. package/dist/rules/set-options.d.ts +109 -0
  162. package/dist/rules/set-options.js +201 -0
  163. package/dist/rules/shebang-interpreter.d.ts +18 -0
  164. package/dist/rules/shebang-interpreter.js +81 -0
  165. package/dist/rules/shebang-matches-dialect.d.ts +12 -0
  166. package/dist/rules/shebang-matches-dialect.js +69 -0
  167. package/dist/rules/transparent-wrappers.d.ts +52 -0
  168. package/dist/rules/transparent-wrappers.js +99 -0
  169. package/dist/rules/user-pattern.d.ts +63 -0
  170. package/dist/rules/user-pattern.js +184 -0
  171. package/dist/syntax/analyze.d.ts +19 -0
  172. package/dist/syntax/analyze.js +21 -0
  173. package/dist/syntax/index.d.ts +23 -0
  174. package/dist/syntax/index.js +25 -0
  175. package/dist/version.d.ts +0 -1
  176. package/dist/version.js +0 -1
  177. package/docs/compat.md +354 -0
  178. package/docs/custom-rule-tutorial.md +780 -34
  179. package/docs/declaration-conventions.md +478 -0
  180. package/docs/examples/audit-wrapper-pack.mjs +15 -0
  181. package/docs/examples/dep-line-convention.mjs +22 -0
  182. package/docs/examples/eslint.config.mjs +3 -2
  183. package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
  184. package/docs/examples/no-backticks-rule-tester.mjs +106 -0
  185. package/docs/examples/no-backticks-rule.mjs +60 -0
  186. package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
  187. package/docs/examples/no-function-keyword-rule.mjs +59 -0
  188. package/docs/examples/no-listed-commands-rule.ts +63 -0
  189. package/docs/examples/no-rm-rf-rule.mjs +26 -15
  190. package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
  191. package/docs/examples/sidecar-convention.mjs +38 -0
  192. package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
  193. package/docs/examples/sorted-declarations-rule.mjs +52 -0
  194. package/docs/reading-nodes.md +159 -0
  195. package/docs/rules/README.md +79 -0
  196. package/docs/rules/_template.md +83 -0
  197. package/docs/rules/compat/no-append-all-redirect.md +87 -0
  198. package/docs/rules/compat/no-associative-arrays.md +95 -0
  199. package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
  200. package/docs/rules/compat/no-case-attributes.md +82 -0
  201. package/docs/rules/compat/no-case-fallthrough.md +90 -0
  202. package/docs/rules/compat/no-case-modification.md +74 -0
  203. package/docs/rules/compat/no-coproc.md +76 -0
  204. package/docs/rules/compat/no-declare-g.md +80 -0
  205. package/docs/rules/compat/no-double-bracket.md +72 -0
  206. package/docs/rules/compat/no-epoch-variables.md +79 -0
  207. package/docs/rules/compat/no-fd-variable.md +78 -0
  208. package/docs/rules/compat/no-globstar.md +74 -0
  209. package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
  210. package/docs/rules/compat/no-let.md +78 -0
  211. package/docs/rules/compat/no-mapfile.md +118 -0
  212. package/docs/rules/compat/no-nameref.md +81 -0
  213. package/docs/rules/compat/no-negative-array-index.md +87 -0
  214. package/docs/rules/compat/no-nounset-empty-array.md +103 -0
  215. package/docs/rules/compat/no-parameter-transformation.md +94 -0
  216. package/docs/rules/compat/no-pipe-all.md +85 -0
  217. package/docs/rules/compat/no-printf-time-format.md +75 -0
  218. package/docs/rules/compat/no-source.md +98 -0
  219. package/docs/rules/compat/no-test-v.md +82 -0
  220. package/docs/rules/compat/no-unicode-escape.md +91 -0
  221. package/docs/rules/compat/no-wait-n.md +85 -0
  222. package/docs/rules/max-script-complexity.md +109 -0
  223. package/docs/rules/no-dynamic-invocation.md +139 -0
  224. package/docs/rules/no-eval.md +106 -0
  225. package/docs/rules/no-masked-exit-status.md +239 -0
  226. package/docs/rules/no-secret-env-reads.md +173 -0
  227. package/docs/rules/no-secret-flags.md +169 -0
  228. package/docs/rules/no-unchecked-cd.md +146 -0
  229. package/docs/rules/no-undeclared-commands.md +141 -0
  230. package/docs/rules/no-unused-declarations.md +216 -0
  231. package/docs/rules/prefer-local.md +221 -0
  232. package/docs/rules/quote-expansions.md +157 -0
  233. package/docs/rules/require-network-wrapper.md +171 -0
  234. package/docs/rules/require-shebang.md +157 -0
  235. package/docs/rules/require-strict-mode.md +253 -0
  236. package/docs/rules/require-tty-guard.md +131 -0
  237. package/docs/rules/shebang-matches-dialect.md +120 -0
  238. package/docs/selectors.md +2 -2
  239. package/docs/shellcheck.md +87 -0
  240. package/docs/untrusted-code.md +440 -0
  241. package/docs/wrapper-rules.md +206 -0
  242. package/package.json +26 -20
  243. package/dist/index.d.ts.map +0 -1
  244. package/dist/index.js.map +0 -1
  245. package/dist/language/selector-classes.d.ts.map +0 -1
  246. package/dist/language/selector-classes.js.map +0 -1
  247. package/dist/language/shell-language-options.d.ts.map +0 -1
  248. package/dist/language/shell-language-options.js.map +0 -1
  249. package/dist/language/shell-language.d.ts.map +0 -1
  250. package/dist/language/shell-language.js.map +0 -1
  251. package/dist/language/shell-source-code.d.ts.map +0 -1
  252. package/dist/language/shell-source-code.js.map +0 -1
  253. package/dist/language/source-coordinates.d.ts.map +0 -1
  254. package/dist/language/source-coordinates.js.map +0 -1
  255. package/dist/language/syntax-view.d.ts.map +0 -1
  256. package/dist/language/syntax-view.js.map +0 -1
  257. package/dist/language/word-resolution.d.ts.map +0 -1
  258. package/dist/language/word-resolution.js.map +0 -1
  259. package/dist/version.d.ts.map +0 -1
  260. package/dist/version.js.map +0 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `sh/no-undeclared-commands`: reports external commands a script uses but
3
+ * does not declare.
4
+ *
5
+ * A script silently depends on every external command it runs, and any of
6
+ * them may be missing on the machine it lands on. When a project states a
7
+ * script's dependencies up front, this rule keeps that statement complete.
8
+ *
9
+ * The rule splits the question in two:
10
+ *
11
+ * - **Usage** is a fact about the syntax tree, computed by the internal
12
+ * command harvest (`../command-harvest/harvest-commands.ts`): every static
13
+ * command name, minus the dialect's builtins and the file's own functions,
14
+ * following transparent wrappers.
15
+ * - **Declaration** is project policy, answered by the consumer's
16
+ * `DeclarationConvention`, bound in `settings.sh.declarationConvention`
17
+ * and resolved with `resolveDeclarationConvention`. With no convention
18
+ * bound the rule is inert: shipping a default would impose one project's
19
+ * declaration syntax on every project.
20
+ *
21
+ * The convention is bound through settings only. The rule has no
22
+ * `declarationConvention` option, because ESLint copies rule options with
23
+ * `structuredClone`, which cannot carry a convention's methods.
24
+ *
25
+ * Each undeclared command reports once, at the word naming it in its first
26
+ * use. The rule offers no fix: adding a dependency is a decision for the
27
+ * author. The report instead says how to declare the command, in the
28
+ * convention's own words when it has a `declarationHint` hook, since only the
29
+ * convention knows its syntax.
30
+ */
31
+ import { harvestCommands } from '../command-harvest/harvest-commands.js';
32
+ import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
33
+ import { createRule } from './create-rule.js';
34
+ import { readDeclaredCommands, toLintedFile, } from '../declaration-conventions/read-declared-commands.js';
35
+ import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
36
+ import { networkWrapperSpec } from './resolve-network-wrapper.js';
37
+ /** The registry name of this rule (`sh/no-undeclared-commands` in a user's config). */
38
+ const RULE_NAME = 'no-undeclared-commands';
39
+ /** The report's second sentence when the convention offers no hint for a command. */
40
+ const GENERIC_HINT = "Declare it with the project's declaration convention.";
41
+ /**
42
+ * The convention's hint for `command`, or {@link GENERIC_HINT} when it has
43
+ * none. Throws when the hook returns something other than `null` or a
44
+ * non-empty single-line string, so a broken hook surfaces as a readable
45
+ * configuration error instead of a garbled report.
46
+ */
47
+ function howToDeclare(convention, command, sourceCode, file) {
48
+ if (convention.declarationHint === undefined)
49
+ return GENERIC_HINT;
50
+ const hint = convention.declarationHint(command, sourceCode, file);
51
+ if (hint === null)
52
+ return GENERIC_HINT;
53
+ if (typeof hint !== 'string' || hint.trim() === '' || /[\r\n\u2028\u2029]/u.test(hint)) {
54
+ throw new Error(`Invalid settings.sh.declarationConvention: \`declarationHint\` must return null or a non-empty single-line string, but returned ${typeof hint === 'string' ? JSON.stringify(hint) : typeof hint} for ${JSON.stringify(command)}.`);
55
+ }
56
+ return hint;
57
+ }
58
+ /**
59
+ * The `sh/no-undeclared-commands` rule module.
60
+ *
61
+ * @internal
62
+ */
63
+ export const noUndeclaredCommands = createRule({
64
+ name: RULE_NAME,
65
+ meta: {
66
+ type: 'problem',
67
+ docs: {
68
+ description: 'Require every external command a script uses to be declared by the configured declaration convention',
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
+ ],
83
+ messages: {
84
+ undeclaredCommand: "'{{command}}' is used but not declared. {{howToDeclare}}",
85
+ },
86
+ },
87
+ create(context) {
88
+ const convention = resolveDeclarationConvention(context);
89
+ if (convention === null) {
90
+ return {};
91
+ }
92
+ const { sourceCode } = context;
93
+ const transparentWrappers = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
94
+ return {
95
+ File() {
96
+ // Only names matter here, so every kind of declaration counts the same.
97
+ const declared = new Set(readDeclaredCommands(convention, context).map(({ name }) => name));
98
+ for (const { name, node } of harvestCommands(sourceCode, {
99
+ transparentWrappers,
100
+ }).values()) {
101
+ if (declared.has(name))
102
+ continue;
103
+ context.report({
104
+ node,
105
+ messageId: 'undeclaredCommand',
106
+ data: {
107
+ command: name,
108
+ howToDeclare: howToDeclare(convention, name, sourceCode, toLintedFile(context)),
109
+ },
110
+ });
111
+ }
112
+ },
113
+ };
114
+ },
115
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The rule's single options object. It mirrors `no-undeclared-commands`'
3
+ * options, so a project configures both rules with the same wrappers and
4
+ * they compute the same usage.
5
+ *
6
+ * @internal
7
+ */
8
+ export interface NoUnusedDeclarationsOptions {
9
+ /**
10
+ * Names of project-specific transparent wrappers (`with-retry`), added to
11
+ * the default wrapper table (see `harvestWrapperTable`). The word after a
12
+ * listed wrapper is the command it runs, so `with-retry curl` uses `curl`
13
+ * as well as `with-retry`, and `with-retry "$tool"` runs a command the
14
+ * file cannot name.
15
+ */
16
+ readonly transparentWrappers?: readonly string[];
17
+ }
18
+ /**
19
+ * The `sh/no-unused-declarations` rule module.
20
+ *
21
+ * @internal
22
+ */
23
+ export declare const noUnusedDeclarations: import("./define-shell-rule.js").ShellRule<[NoUnusedDeclarationsOptions?], "unusedDeclaration" | "unusedFileDeclaration" | "unusedDescribedDeclaration">;
@@ -0,0 +1,259 @@
1
+ import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
2
+ import { harvestCommands } from '../command-harvest/harvest-commands.js';
3
+ import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
4
+ import { createRule } from './create-rule.js';
5
+ import { readDeclaredCommands } from '../declaration-conventions/read-declared-commands.js';
6
+ import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
7
+ import { networkWrapperSpec } from './resolve-network-wrapper.js';
8
+ import { SHELL_WRAPPER } from './transparent-wrappers.js';
9
+ /** The registry name of this rule (`sh/no-unused-declarations` in a user's config). */
10
+ const RULE_NAME = 'no-unused-declarations';
11
+ /**
12
+ * A shell name (`cleanup`, `_on_exit`): the form of a `trap` action that
13
+ * names a single function or command rather than a code string.
14
+ *
15
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_235 — Name
16
+ */
17
+ const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
18
+ /** A `trap` option cluster that lists or prints traps instead of setting one (`-l`, `-p`, `-P`). */
19
+ const TRAP_PRINT_OPTIONS = /^-[lpP]+$/u;
20
+ /** A decimal integer: as `trap`'s first operand, it makes every operand a condition to reset. */
21
+ const UNSIGNED_INTEGER = /^[0-9]+$/u;
22
+ /**
23
+ * Whether `trap`'s `operands` (the words after `trap`) set an action that is a
24
+ * code string, which the shell evaluates later and this rule cannot read.
25
+ *
26
+ * Not a code string: no action at all (`trap`, `trap -p`, `trap -l`); the
27
+ * reset action `-` and the ignore action `''`; a leading integer, which POSIX
28
+ * treats as a condition to reset; and a plain name (`trap cleanup EXIT`),
29
+ * which runs that function or command. A name used as an action is an
30
+ * argument word, so it already counts as used. An action that is not static
31
+ * is a code string the rule cannot know.
32
+ *
33
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#trap
34
+ * @see https://www.gnu.org/software/bash/manual/bash.html#index-trap
35
+ */
36
+ function trapSetsCodeString(operands) {
37
+ let index = 0;
38
+ if (operands.length > 0 && operands[0].static) {
39
+ const { text } = operands[0];
40
+ if (TRAP_PRINT_OPTIONS.test(text))
41
+ return false;
42
+ if (text === '--')
43
+ index = 1;
44
+ }
45
+ if (index >= operands.length)
46
+ return false;
47
+ const action = operands[index];
48
+ if (!action.static)
49
+ return true;
50
+ const { text } = action;
51
+ return !(text === '' || text === '-' || UNSIGNED_INTEGER.test(text) || SHELL_NAME.test(text));
52
+ }
53
+ /** Shell names, from {@link SHELL_WRAPPER}: a shell with no script operand reads code from stdin. */
54
+ const SHELL_NAMES = new Set(SHELL_WRAPPER.names);
55
+ /** The builtins that run another file's code in this shell: POSIX `.` and Bash's `source`. */
56
+ const SOURCE_COMMANDS = new Set(['.', 'source']);
57
+ /**
58
+ * The wrappers whose path forms (`/bin/bash`, `/usr/bin/env`) the completeness
59
+ * check also follows, by basename: shells invoked as commands and `env`,
60
+ * the wrappers that can run an embedded command string. sh-ast matches
61
+ * wrapper names exactly, so without this `/bin/bash -c '…'` would resolve to
62
+ * the static command `/bin/bash`.
63
+ */
64
+ const PATH_FOLLOWED_WRAPPERS = [
65
+ SHELL_WRAPPER,
66
+ ...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => spec.names.includes('env')),
67
+ ];
68
+ /** The basename of `text` when it is a path (contains a slash), else `null`. */
69
+ function pathBasename(text) {
70
+ const slash = text.lastIndexOf('/');
71
+ return slash === -1 ? null : text.slice(slash + 1);
72
+ }
73
+ /**
74
+ * The effective command of `site` under `table`, also following a shell or
75
+ * `env` named by path: while the effective command is a path whose basename
76
+ * names one of {@link PATH_FOLLOWED_WRAPPERS}, that path is added as a
77
+ * wrapper with the same spec and the site is resolved again. This mirrors how
78
+ * `createWrapperRule` follows path-named wrappers. Each round adds a new
79
+ * path, and a site has finitely many words, so the loop ends.
80
+ */
81
+ function resolveEffective(site, table) {
82
+ let current = table;
83
+ for (;;) {
84
+ const resolution = site.resolveInvokedCommand({ transparentWrappers: current });
85
+ const { effective } = resolution;
86
+ if (!effective.static)
87
+ return resolution;
88
+ const path = effective.text;
89
+ const base = pathBasename(path);
90
+ if (base === null || current.some((spec) => spec.names.includes(path)))
91
+ return resolution;
92
+ const spec = PATH_FOLLOWED_WRAPPERS.find((entry) => entry.names.includes(base));
93
+ if (spec === undefined)
94
+ return resolution;
95
+ current = [{ ...spec, names: [path] }, ...current];
96
+ }
97
+ }
98
+ /** A short-option cluster that includes `c` (`-c`, `-ec`, `-xc`): a shell's command-string flag. */
99
+ const COMMAND_STRING_CLUSTER = /^-[A-Za-z]*c[A-Za-z]*$/u;
100
+ /**
101
+ * Whether `argv` names a shell as an argument and later passes it a command
102
+ * string: some word after argv0 is statically a shell name, by name or by
103
+ * basename (`sh`, `/bin/bash`), and a later word is a short-option cluster
104
+ * containing `c`. Many commands run their arguments as a command line
105
+ * (`xargs sh -c '…'`, `find . -exec sh -c '…' \;`, `setsid -f bash -c …`,
106
+ * `busybox sh -c …`), and they are not wrappers this rule follows, so the
107
+ * shell never becomes the effective command. Matching the words directly
108
+ * catches them without modeling each command's options.
109
+ */
110
+ function passesShellCommandString(argv) {
111
+ const shellIndex = argv.findIndex((word, index) => index > 0 && word.static && SHELL_NAMES.has(pathBasename(word.text) ?? word.text));
112
+ return (shellIndex !== -1 &&
113
+ argv.slice(shellIndex + 1).some((word) => word.static && COMMAND_STRING_CLUSTER.test(word.text)));
114
+ }
115
+ /**
116
+ * Whether `site` may run code that cannot be read from the source:
117
+ *
118
+ * - its effective command is not static, including an embedded command
119
+ * string;
120
+ * - it is `eval`;
121
+ * - it is a `trap` that sets a code string;
122
+ * - it is `.` or `source` with an operand, which runs another file;
123
+ * - it is a shell, by name or path, that is the effective command itself.
124
+ * With {@link SHELL_WRAPPER} followed, a script operand would be the
125
+ * effective command and `-c` would be an embedded command, so a shell that
126
+ * is still the effective command reads its code from standard input
127
+ * (`curl x | bash`, `bash < script`, `bash -s`);
128
+ * - it hands a command string to a shell named in its arguments (see
129
+ * {@link passesShellCommandString}).
130
+ *
131
+ * `completenessWrappers` is the table to follow (see the rule's `create`).
132
+ */
133
+ function runsUnknownCode(site, completenessWrappers) {
134
+ if (passesShellCommandString(site.argv))
135
+ return true;
136
+ const resolution = resolveEffective(site, completenessWrappers);
137
+ const { effective, chainIndices } = resolution;
138
+ if (!effective.static)
139
+ return true;
140
+ const { text } = effective;
141
+ if (text === 'eval')
142
+ return true;
143
+ if (SHELL_NAMES.has(pathBasename(text) ?? text))
144
+ return true;
145
+ if (!SOURCE_COMMANDS.has(text) && text !== 'trap')
146
+ return false;
147
+ // `chainIndices` locates the effective command in argv; its operands follow it.
148
+ const effectiveIndex = chainIndices.at(-1);
149
+ const operands = effectiveIndex === undefined ? [] : site.argv.slice(effectiveIndex + 1);
150
+ return text === 'trap' ? trapSetsCodeString(operands) : operands.length > 0;
151
+ }
152
+ /** `text`, and also its basename when `text` is a path (contains a slash). */
153
+ function namesFor(text) {
154
+ const base = pathBasename(text);
155
+ return base === null ? [text] : [text, base];
156
+ }
157
+ /**
158
+ * How a report names where an out-of-script declaration lives: `path:line`
159
+ * (or `path` when the line is unknown) for a file, the description for a
160
+ * described source.
161
+ */
162
+ function sourceText(declared) {
163
+ if (declared.kind === 'described')
164
+ return declared.description;
165
+ return declared.line === undefined ? declared.path : `${declared.path}:${String(declared.line)}`;
166
+ }
167
+ /**
168
+ * The `sh/no-unused-declarations` rule module.
169
+ *
170
+ * @internal
171
+ */
172
+ export const noUnusedDeclarations = createRule({
173
+ name: RULE_NAME,
174
+ meta: {
175
+ type: 'suggestion',
176
+ docs: {
177
+ description: 'Disallow declaring external commands that a script never uses, per the configured declaration convention',
178
+ },
179
+ fixable: 'code',
180
+ schema: [
181
+ {
182
+ type: 'object',
183
+ properties: {
184
+ transparentWrappers: {
185
+ type: 'array',
186
+ items: { type: 'string', minLength: 1 },
187
+ uniqueItems: true,
188
+ },
189
+ },
190
+ additionalProperties: false,
191
+ },
192
+ ],
193
+ messages: {
194
+ unusedDeclaration: "'{{command}}' is declared but never used. Remove the declaration so the script's stated dependencies match what it runs.",
195
+ unusedFileDeclaration: "'{{command}}' is declared in {{source}} but this script never uses it. Remove it there so the script's stated dependencies match what it runs.",
196
+ unusedDescribedDeclaration: "'{{command}}' is declared in {{source}} but this script never uses it. Remove it there so the script's stated dependencies match what it runs.",
197
+ },
198
+ },
199
+ create(context) {
200
+ const convention = resolveDeclarationConvention(context);
201
+ if (convention === null) {
202
+ return {};
203
+ }
204
+ const { sourceCode } = context;
205
+ const transparentWrappers = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
206
+ // The completeness check follows the harvest's table plus shells
207
+ // invoked as commands, so `bash -c '…'` and `sh -c '…'` resolve to an
208
+ // embedded command (as in `no-dynamic-invocation`) instead of stopping
209
+ // at the shell. `env -S` is already embedded in the defaults. Path
210
+ // forms (`/bin/bash`, `/usr/bin/env`) are followed per site by
211
+ // `resolveEffective`.
212
+ const completenessWrappers = [...transparentWrappers, SHELL_WRAPPER];
213
+ // Bound once so the hook keeps its convention as `this`, however it is written.
214
+ const removeDeclaration = convention.removeDeclaration?.bind(convention);
215
+ return {
216
+ File() {
217
+ const sites = sourceCode.getCommandSites();
218
+ if (sites.some((site) => runsUnknownCode(site, completenessWrappers)))
219
+ return;
220
+ const used = new Set();
221
+ for (const name of harvestCommands(sourceCode, { transparentWrappers }).keys()) {
222
+ for (const each of namesFor(name))
223
+ used.add(each);
224
+ }
225
+ for (const site of sites) {
226
+ for (const word of site.argv.slice(1)) {
227
+ if (word.static)
228
+ for (const each of namesFor(word.text))
229
+ used.add(each);
230
+ }
231
+ }
232
+ for (const declared of readDeclaredCommands(convention, context)) {
233
+ if (used.has(declared.name))
234
+ continue;
235
+ if (declared.kind === 'script') {
236
+ context.report({
237
+ loc: {
238
+ start: sourceCode.getLocFromIndex(declared.range[0]),
239
+ end: sourceCode.getLocFromIndex(declared.range[1]),
240
+ },
241
+ messageId: 'unusedDeclaration',
242
+ data: { command: declared.name },
243
+ fix: removeDeclaration
244
+ ? (fixer) => removeDeclaration(sourceCode, declared, fixer)
245
+ : null,
246
+ });
247
+ continue;
248
+ }
249
+ context.report({
250
+ // A start with no end: the file as a whole, not any text in it.
251
+ loc: sourceCode.getLocFromIndex(0),
252
+ messageId: declared.kind === 'file' ? 'unusedFileDeclaration' : 'unusedDescribedDeclaration',
253
+ data: { command: declared.name, source: sourceText(declared) },
254
+ });
255
+ }
256
+ },
257
+ };
258
+ },
259
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The rule's single options object.
3
+ *
4
+ * @internal
5
+ */
6
+ export interface PreferLocalOptions {
7
+ /** Variable names that may be assigned in a function without `local`. */
8
+ readonly allow?: readonly string[];
9
+ /**
10
+ * A regular expression (source text, matched with the `u` flag); a name that
11
+ * matches it may be assigned without `local`. Defaults to UPPER_CASE names.
12
+ */
13
+ readonly allowPattern?: string;
14
+ }
15
+ /** Built-in rule `sh/prefer-local`. @internal */
16
+ export declare const preferLocal: import("./define-shell-rule.js").ShellRule<[PreferLocalOptions], "preferLocal">;
@@ -0,0 +1,249 @@
1
+ import { childNodes, walkShell } from '../language/syntax-view.js';
2
+ import { createRule } from './create-rule.js';
3
+ import { compileUserPattern } from './user-pattern.js';
4
+ import { lineBreakOf } from '../language/line-breaks.js';
5
+ /** The `allowPattern` used when none is configured: UPPER_CASE globals. */
6
+ const DEFAULT_ALLOW_PATTERN = '^[A-Z][A-Z0-9_]*$';
7
+ /** A valid shell variable name; anything else is not a binding target. */
8
+ const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
9
+ /** Declaration builtins whose names are function-scoped unless `-g` is given. */
10
+ const SCOPED_DECLARATIONS = new Set(['local', 'declare', 'typeset']);
11
+ /** `read` options that consume an argument that is not a variable name. */
12
+ const READ_OPTIONS_WITH_ARGUMENT = new Set([
13
+ 'p',
14
+ 'd',
15
+ 'n',
16
+ 'N',
17
+ 't',
18
+ 'u',
19
+ 'i',
20
+ ]);
21
+ /**
22
+ * The variables a `read` command assigns, skipping its options. Option
23
+ * clusters follow `read`'s own parsing: a letter that takes an argument uses
24
+ * the rest of the cluster, or else the next word. `-a`'s array name is an
25
+ * ordinary target word. Words that are not static are not guessed at.
26
+ */
27
+ function readTargets(args, sourceCode) {
28
+ const targets = [];
29
+ let optionsEnded = false;
30
+ let nextIsOptionArgument = false;
31
+ for (const word of args.slice(1)) {
32
+ const text = sourceCode.resolveWordText(word);
33
+ if (nextIsOptionArgument) {
34
+ nextIsOptionArgument = false;
35
+ continue;
36
+ }
37
+ if (text === null)
38
+ continue;
39
+ if (!optionsEnded && text === '--') {
40
+ optionsEnded = true;
41
+ continue;
42
+ }
43
+ if (!optionsEnded && text.startsWith('-') && text.length > 1) {
44
+ for (let index = 1; index < text.length; index += 1) {
45
+ const letter = text.charAt(index);
46
+ if (READ_OPTIONS_WITH_ARGUMENT.has(letter)) {
47
+ nextIsOptionArgument = index === text.length - 1;
48
+ break;
49
+ }
50
+ }
51
+ continue;
52
+ }
53
+ if (VARIABLE_NAME.test(text))
54
+ targets.push({ name: text, kind: 'assign', node: word });
55
+ }
56
+ return targets;
57
+ }
58
+ /**
59
+ * The variables `node` assigns or declares, itself only (not its children).
60
+ * `declare`/`typeset` with a `-g` flag, and every non-scoped declaration
61
+ * (`export`, `readonly`), bind their names as intentional globals.
62
+ */
63
+ function bindingsOf(node, sourceCode) {
64
+ if (node.type === 'ForClause') {
65
+ const { loop } = node;
66
+ if (loop?.type !== 'WordIter' || loop.name === undefined)
67
+ return [];
68
+ return [{ name: loop.name.value, kind: 'assign', node: loop.name }];
69
+ }
70
+ if (node.type === 'CallExpr') {
71
+ if (node.args.length === 0) {
72
+ return node.assigns.flatMap((assign) => assign.name === undefined
73
+ ? []
74
+ : [{ name: assign.name.value, kind: 'assign', node: assign }]);
75
+ }
76
+ return sourceCode.getCommandName(node) === 'read' ? readTargets(node.args, sourceCode) : [];
77
+ }
78
+ if (node.type === 'DeclClause') {
79
+ const variant = node.variant?.value;
80
+ const flags = node.args
81
+ .filter((arg) => arg.naked && arg.name === undefined && arg.value !== undefined)
82
+ .map((arg) => (arg.value === undefined ? null : sourceCode.resolveWordText(arg.value)));
83
+ const isGlobal = variant === undefined ||
84
+ !SCOPED_DECLARATIONS.has(variant) ||
85
+ flags.some((flag) => flag !== null && /^-[A-Za-z]*g/u.test(flag));
86
+ return node.args.flatMap((arg) => arg.name === undefined
87
+ ? []
88
+ : [
89
+ {
90
+ name: arg.name.value,
91
+ kind: isGlobal ? 'global' : 'local',
92
+ node: arg,
93
+ },
94
+ ]);
95
+ }
96
+ return [];
97
+ }
98
+ /** Node types whose contents run in a child process and cannot leak assignments. */
99
+ const ISOLATED_SCOPES = new Set(['Subshell', 'CmdSubst', 'ProcSubst']);
100
+ /** `BinaryCmd.op` values for `|` and `|&`, the pipeline operators. */
101
+ const PIPE_OPS = new Set([13, 14]);
102
+ /**
103
+ * Whether the file enables `shopt -s lastpipe`. With it, the last element of a
104
+ * pipeline runs in the current shell, so what that element assigns does leak.
105
+ * The option is read file-wide, wherever the `shopt` appears, because it
106
+ * applies to every pipeline that runs after it.
107
+ */
108
+ function enablesLastpipe(root, sourceCode) {
109
+ let enabled = false;
110
+ walkShell(root, (node) => {
111
+ if (node.type !== 'CallExpr' || sourceCode.getCommandName(node) !== 'shopt')
112
+ return;
113
+ const words = node.args.slice(1).map((word) => sourceCode.resolveWordText(word));
114
+ if (words.includes('-s') && words.includes('lastpipe'))
115
+ enabled = true;
116
+ });
117
+ return enabled;
118
+ }
119
+ /**
120
+ * Every binding in `root`'s own scope, in source order. Nested function
121
+ * definitions and isolated scopes (subshells, substitutions) are not entered.
122
+ * Pipeline elements run in subshells, so only the last element of a pipeline
123
+ * is entered, and only when `lastpipe` is enabled.
124
+ */
125
+ function scopeBindings(root, sourceCode, lastpipe) {
126
+ const found = [];
127
+ const visit = (node) => {
128
+ if (node.type === 'FuncDecl' || ISOLATED_SCOPES.has(node.type))
129
+ return;
130
+ if (node.type === 'BinaryCmd' && PIPE_OPS.has(node.op)) {
131
+ if (lastpipe && node.y !== undefined)
132
+ visit(node.y);
133
+ return;
134
+ }
135
+ found.push(...bindingsOf(node, sourceCode));
136
+ for (const child of childNodes(node))
137
+ visit(child);
138
+ };
139
+ visit(root);
140
+ return found.sort((a, b) => a.node.range[0] - b.node.range[0]);
141
+ }
142
+ /** Built-in rule `sh/prefer-local`. @internal */
143
+ export const preferLocal = createRule({
144
+ name: 'prefer-local',
145
+ meta: {
146
+ type: 'suggestion',
147
+ docs: {
148
+ description: 'require variables assigned inside a function to be declared `local`, unless intentionally global',
149
+ },
150
+ fixable: 'code',
151
+ schema: [
152
+ {
153
+ type: 'object',
154
+ properties: {
155
+ allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
156
+ allowPattern: { type: 'string' },
157
+ },
158
+ additionalProperties: false,
159
+ },
160
+ ],
161
+ defaultOptions: [{ allow: [], allowPattern: DEFAULT_ALLOW_PATTERN }],
162
+ messages: {
163
+ preferLocal: '`{{name}}` is assigned in function `{{function}}` without being declared `local`, so it leaks into the global scope. Declare it with `local {{name}}`.',
164
+ },
165
+ },
166
+ create(context) {
167
+ const { sourceCode } = context;
168
+ // `local` is not POSIX: under `posix` there is nothing to declare with.
169
+ if (sourceCode.dialect === 'posix')
170
+ return {};
171
+ const [options] = context.options;
172
+ const allowed = new Set(options.allow ?? []);
173
+ const allowPattern = compileUserPattern('sh/prefer-local', 'allowPattern', options.allowPattern ?? DEFAULT_ALLOW_PATTERN, 'u');
174
+ const text = sourceCode.text;
175
+ const lastpipe = enablesLastpipe(sourceCode.ast, sourceCode);
176
+ // Names assigned or declared outside every function, with where each
177
+ // binding ends: assigned before a function is defined, they are globals
178
+ // the function is meant to share.
179
+ const topLevelBindings = [];
180
+ let topLevelCollected = false;
181
+ const topLevelBefore = (position) => {
182
+ if (!topLevelCollected) {
183
+ topLevelBindings.push(...scopeBindings(sourceCode.ast, sourceCode, lastpipe));
184
+ topLevelCollected = true;
185
+ }
186
+ return new Set(topLevelBindings.filter((b) => b.node.range[1] <= position).map((b) => b.name));
187
+ };
188
+ /**
189
+ * The one `local` declaration that covers `names`, as `[index, text]`, or
190
+ * `null` when the function has no `{ … }` block with a first statement to
191
+ * insert before. Every report of a function carries this same insertion
192
+ * (with the function's whole name list), because fixes that insert at one
193
+ * offset conflict: ESLint applies one per pass and stops after 10, so a
194
+ * function with more than 10 names would otherwise never settle.
195
+ */
196
+ const insertionFor = (func, names) => {
197
+ const block = func.body?.cmd;
198
+ if (block?.type !== 'Block')
199
+ return null;
200
+ const first = block.stmts.at(0);
201
+ if (first === undefined)
202
+ return null;
203
+ const lineStart = Math.max(text.lastIndexOf('\n', first.range[0] - 1), text.lastIndexOf('\r', first.range[0] - 1));
204
+ const indent = text.slice(lineStart + 1, first.range[0]);
205
+ const separator = /^[ \t]*$/u.test(indent) ? lineBreakOf(text) + indent : '; ';
206
+ return [first.range[0], `local ${names.join(' ')}${separator}`];
207
+ };
208
+ return {
209
+ FuncDecl(func) {
210
+ const { body } = func;
211
+ if (body === undefined || body.cmd?.type === 'Subshell')
212
+ return;
213
+ const global = topLevelBefore(func.range[0]);
214
+ const declared = new Set();
215
+ const functionName = func.name?.value ?? '';
216
+ const leaks = [];
217
+ for (const binding of scopeBindings(body, sourceCode, lastpipe)) {
218
+ const { name } = binding;
219
+ if (binding.kind !== 'assign') {
220
+ declared.add(name);
221
+ continue;
222
+ }
223
+ if (declared.has(name) ||
224
+ global.has(name) ||
225
+ allowed.has(name) ||
226
+ allowPattern.test(name)) {
227
+ continue;
228
+ }
229
+ declared.add(name);
230
+ leaks.push(binding);
231
+ }
232
+ if (leaks.length === 0)
233
+ return;
234
+ const insertion = insertionFor(func, leaks.map((binding) => binding.name));
235
+ for (const binding of leaks) {
236
+ const { name } = binding;
237
+ context.report({
238
+ node: binding.node,
239
+ messageId: 'preferLocal',
240
+ data: { name, function: functionName },
241
+ fix: insertion === null
242
+ ? null
243
+ : (fixer) => fixer.insertTextBeforeRange([insertion[0], insertion[0]], insertion[1]),
244
+ });
245
+ }
246
+ },
247
+ };
248
+ },
249
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message ids this rule reports with: `unquotedExpansion` for the problem,
3
+ * `wrapInQuotes` for its suggestion.
4
+ *
5
+ * @internal
6
+ */
7
+ export type QuoteExpansionsMessageId = 'unquotedExpansion' | 'wrapInQuotes';
8
+ /**
9
+ * The `sh/quote-expansions` rule module.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare const quoteExpansions: import("./define-shell-rule.js").ShellRule<[], QuoteExpansionsMessageId>;