@eslint-sh/eslint-plugin 0.4.0 → 0.6.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 (256) hide show
  1. package/README.md +223 -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 +50 -0
  18. package/dist/configs/posix-portable.d.ts +40 -0
  19. package/dist/configs/posix-portable.js +52 -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-case-attributes.d.ts +12 -0
  66. package/dist/rules/compat/no-case-attributes.js +63 -0
  67. package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
  68. package/dist/rules/compat/no-case-fallthrough.js +67 -0
  69. package/dist/rules/compat/no-case-modification.d.ts +35 -0
  70. package/dist/rules/compat/no-case-modification.js +86 -0
  71. package/dist/rules/compat/no-coproc.d.ts +12 -0
  72. package/dist/rules/compat/no-coproc.js +47 -0
  73. package/dist/rules/compat/no-declare-g.d.ts +12 -0
  74. package/dist/rules/compat/no-declare-g.js +59 -0
  75. package/dist/rules/compat/no-double-bracket.d.ts +12 -0
  76. package/dist/rules/compat/no-double-bracket.js +52 -0
  77. package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
  78. package/dist/rules/compat/no-epoch-variables.js +57 -0
  79. package/dist/rules/compat/no-fd-variable.d.ts +12 -0
  80. package/dist/rules/compat/no-fd-variable.js +48 -0
  81. package/dist/rules/compat/no-globstar.d.ts +12 -0
  82. package/dist/rules/compat/no-globstar.js +39 -0
  83. package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
  84. package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
  85. package/dist/rules/compat/no-let.d.ts +12 -0
  86. package/dist/rules/compat/no-let.js +62 -0
  87. package/dist/rules/compat/no-mapfile.d.ts +13 -0
  88. package/dist/rules/compat/no-mapfile.js +122 -0
  89. package/dist/rules/compat/no-nameref.d.ts +12 -0
  90. package/dist/rules/compat/no-nameref.js +44 -0
  91. package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
  92. package/dist/rules/compat/no-negative-array-index.js +123 -0
  93. package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
  94. package/dist/rules/compat/no-nounset-empty-array.js +158 -0
  95. package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
  96. package/dist/rules/compat/no-parameter-transformation.js +123 -0
  97. package/dist/rules/compat/no-pipe-all.d.ts +13 -0
  98. package/dist/rules/compat/no-pipe-all.js +68 -0
  99. package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
  100. package/dist/rules/compat/no-printf-time-format.js +99 -0
  101. package/dist/rules/compat/no-source.d.ts +13 -0
  102. package/dist/rules/compat/no-source.js +103 -0
  103. package/dist/rules/compat/no-test-v.d.ts +12 -0
  104. package/dist/rules/compat/no-test-v.js +280 -0
  105. package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
  106. package/dist/rules/compat/no-unicode-escape.js +120 -0
  107. package/dist/rules/compat/no-wait-n.d.ts +12 -0
  108. package/dist/rules/compat/no-wait-n.js +73 -0
  109. package/dist/rules/compat/operator-range.d.ts +10 -0
  110. package/dist/rules/compat/operator-range.js +15 -0
  111. package/dist/rules/compat/posix-absent-command.d.ts +12 -0
  112. package/dist/rules/compat/posix-absent-command.js +134 -0
  113. package/dist/rules/compat/shopt-options.d.ts +9 -0
  114. package/dist/rules/compat/shopt-options.js +63 -0
  115. package/dist/rules/compat/version-gates.d.ts +76 -0
  116. package/dist/rules/compat/version-gates.js +197 -0
  117. package/dist/rules/create-rule.d.ts +91 -0
  118. package/dist/rules/create-rule.js +51 -0
  119. package/dist/rules/create-wrapper-rule.d.ts +166 -0
  120. package/dist/rules/create-wrapper-rule.js +407 -0
  121. package/dist/rules/define-shell-rule.d.ts +184 -0
  122. package/dist/rules/define-shell-rule.js +56 -0
  123. package/dist/rules/index.d.ts +26 -0
  124. package/dist/rules/index.js +88 -0
  125. package/dist/rules/max-script-complexity.d.ts +28 -0
  126. package/dist/rules/max-script-complexity.js +130 -0
  127. package/dist/rules/no-dynamic-invocation.d.ts +35 -0
  128. package/dist/rules/no-dynamic-invocation.js +117 -0
  129. package/dist/rules/no-eval.d.ts +13 -0
  130. package/dist/rules/no-eval.js +89 -0
  131. package/dist/rules/no-masked-exit-status.d.ts +21 -0
  132. package/dist/rules/no-masked-exit-status.js +667 -0
  133. package/dist/rules/no-secret-env-reads.d.ts +24 -0
  134. package/dist/rules/no-secret-env-reads.js +133 -0
  135. package/dist/rules/no-secret-flags.d.ts +32 -0
  136. package/dist/rules/no-secret-flags.js +162 -0
  137. package/dist/rules/no-unchecked-cd.d.ts +44 -0
  138. package/dist/rules/no-unchecked-cd.js +250 -0
  139. package/dist/rules/no-undeclared-commands.d.ts +20 -0
  140. package/dist/rules/no-undeclared-commands.js +115 -0
  141. package/dist/rules/no-unused-declarations.d.ts +23 -0
  142. package/dist/rules/no-unused-declarations.js +259 -0
  143. package/dist/rules/prefer-local.d.ts +16 -0
  144. package/dist/rules/prefer-local.js +249 -0
  145. package/dist/rules/quote-expansions.d.ts +13 -0
  146. package/dist/rules/quote-expansions.js +229 -0
  147. package/dist/rules/require-network-wrapper.d.ts +40 -0
  148. package/dist/rules/require-network-wrapper.js +244 -0
  149. package/dist/rules/require-shebang.d.ts +29 -0
  150. package/dist/rules/require-shebang.js +101 -0
  151. package/dist/rules/require-strict-mode.d.ts +52 -0
  152. package/dist/rules/require-strict-mode.js +177 -0
  153. package/dist/rules/require-tty-guard.d.ts +13 -0
  154. package/dist/rules/require-tty-guard.js +289 -0
  155. package/dist/rules/resolve-declaration-convention.d.ts +96 -0
  156. package/dist/rules/resolve-declaration-convention.js +109 -0
  157. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  158. package/dist/rules/resolve-network-wrapper.js +116 -0
  159. package/dist/rules/set-options.d.ts +109 -0
  160. package/dist/rules/set-options.js +201 -0
  161. package/dist/rules/shebang-interpreter.d.ts +18 -0
  162. package/dist/rules/shebang-interpreter.js +81 -0
  163. package/dist/rules/shebang-matches-dialect.d.ts +12 -0
  164. package/dist/rules/shebang-matches-dialect.js +69 -0
  165. package/dist/rules/transparent-wrappers.d.ts +52 -0
  166. package/dist/rules/transparent-wrappers.js +99 -0
  167. package/dist/rules/user-pattern.d.ts +63 -0
  168. package/dist/rules/user-pattern.js +184 -0
  169. package/dist/syntax/analyze.d.ts +19 -0
  170. package/dist/syntax/analyze.js +21 -0
  171. package/dist/syntax/index.d.ts +23 -0
  172. package/dist/syntax/index.js +25 -0
  173. package/dist/version.d.ts +0 -1
  174. package/dist/version.js +0 -1
  175. package/docs/compat.md +347 -0
  176. package/docs/custom-rule-tutorial.md +780 -34
  177. package/docs/declaration-conventions.md +478 -0
  178. package/docs/examples/audit-wrapper-pack.mjs +15 -0
  179. package/docs/examples/dep-line-convention.mjs +22 -0
  180. package/docs/examples/eslint.config.mjs +3 -2
  181. package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
  182. package/docs/examples/no-backticks-rule-tester.mjs +106 -0
  183. package/docs/examples/no-backticks-rule.mjs +60 -0
  184. package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
  185. package/docs/examples/no-function-keyword-rule.mjs +59 -0
  186. package/docs/examples/no-listed-commands-rule.ts +63 -0
  187. package/docs/examples/no-rm-rf-rule.mjs +26 -15
  188. package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
  189. package/docs/examples/sidecar-convention.mjs +38 -0
  190. package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
  191. package/docs/examples/sorted-declarations-rule.mjs +52 -0
  192. package/docs/reading-nodes.md +159 -0
  193. package/docs/rules/README.md +78 -0
  194. package/docs/rules/_template.md +83 -0
  195. package/docs/rules/compat/no-append-all-redirect.md +87 -0
  196. package/docs/rules/compat/no-associative-arrays.md +95 -0
  197. package/docs/rules/compat/no-case-attributes.md +82 -0
  198. package/docs/rules/compat/no-case-fallthrough.md +90 -0
  199. package/docs/rules/compat/no-case-modification.md +74 -0
  200. package/docs/rules/compat/no-coproc.md +76 -0
  201. package/docs/rules/compat/no-declare-g.md +80 -0
  202. package/docs/rules/compat/no-double-bracket.md +72 -0
  203. package/docs/rules/compat/no-epoch-variables.md +79 -0
  204. package/docs/rules/compat/no-fd-variable.md +78 -0
  205. package/docs/rules/compat/no-globstar.md +74 -0
  206. package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
  207. package/docs/rules/compat/no-let.md +78 -0
  208. package/docs/rules/compat/no-mapfile.md +118 -0
  209. package/docs/rules/compat/no-nameref.md +81 -0
  210. package/docs/rules/compat/no-negative-array-index.md +87 -0
  211. package/docs/rules/compat/no-nounset-empty-array.md +103 -0
  212. package/docs/rules/compat/no-parameter-transformation.md +94 -0
  213. package/docs/rules/compat/no-pipe-all.md +85 -0
  214. package/docs/rules/compat/no-printf-time-format.md +75 -0
  215. package/docs/rules/compat/no-source.md +98 -0
  216. package/docs/rules/compat/no-test-v.md +82 -0
  217. package/docs/rules/compat/no-unicode-escape.md +91 -0
  218. package/docs/rules/compat/no-wait-n.md +85 -0
  219. package/docs/rules/max-script-complexity.md +109 -0
  220. package/docs/rules/no-dynamic-invocation.md +139 -0
  221. package/docs/rules/no-eval.md +106 -0
  222. package/docs/rules/no-masked-exit-status.md +239 -0
  223. package/docs/rules/no-secret-env-reads.md +173 -0
  224. package/docs/rules/no-secret-flags.md +169 -0
  225. package/docs/rules/no-unchecked-cd.md +146 -0
  226. package/docs/rules/no-undeclared-commands.md +141 -0
  227. package/docs/rules/no-unused-declarations.md +216 -0
  228. package/docs/rules/prefer-local.md +221 -0
  229. package/docs/rules/quote-expansions.md +157 -0
  230. package/docs/rules/require-network-wrapper.md +171 -0
  231. package/docs/rules/require-shebang.md +157 -0
  232. package/docs/rules/require-strict-mode.md +253 -0
  233. package/docs/rules/require-tty-guard.md +128 -0
  234. package/docs/rules/shebang-matches-dialect.md +120 -0
  235. package/docs/selectors.md +2 -2
  236. package/docs/shellcheck.md +86 -0
  237. package/docs/wrapper-rules.md +206 -0
  238. package/package.json +26 -20
  239. package/dist/index.d.ts.map +0 -1
  240. package/dist/index.js.map +0 -1
  241. package/dist/language/selector-classes.d.ts.map +0 -1
  242. package/dist/language/selector-classes.js.map +0 -1
  243. package/dist/language/shell-language-options.d.ts.map +0 -1
  244. package/dist/language/shell-language-options.js.map +0 -1
  245. package/dist/language/shell-language.d.ts.map +0 -1
  246. package/dist/language/shell-language.js.map +0 -1
  247. package/dist/language/shell-source-code.d.ts.map +0 -1
  248. package/dist/language/shell-source-code.js.map +0 -1
  249. package/dist/language/source-coordinates.d.ts.map +0 -1
  250. package/dist/language/source-coordinates.js.map +0 -1
  251. package/dist/language/syntax-view.d.ts.map +0 -1
  252. package/dist/language/syntax-view.js.map +0 -1
  253. package/dist/language/word-resolution.d.ts.map +0 -1
  254. package/dist/language/word-resolution.js.map +0 -1
  255. package/dist/version.d.ts.map +0 -1
  256. package/dist/version.js.map +0 -1
@@ -0,0 +1,33 @@
1
+ import type { DeclarationConvention } from './declaration-convention.js';
2
+ /**
3
+ * The reference {@link DeclarationConvention}: a script declares the external
4
+ * commands it uses in `# uses:` comments in its header.
5
+ *
6
+ * ```sh
7
+ * #!/usr/bin/env bash
8
+ * # uses: jq curl
9
+ * # uses: git
10
+ * set -euo pipefail
11
+ * ```
12
+ *
13
+ * declares `jq`, `curl`, and `git`. The header is the run of blank and
14
+ * comment-only lines at the top of the file; a `# uses:` comment after the
15
+ * first command is not a declaration. Several pragma lines accumulate, names
16
+ * are whitespace-separated, and each declaration is `kind: 'script'` with a
17
+ * `range` that covers exactly that name. A word starting with `#` ends the list and begins a trailing
18
+ * remark: `# uses: jq # for json` declares only `jq`.
19
+ *
20
+ * Its `removeDeclaration` hook deletes one name and the whitespace that
21
+ * separated it, or the whole pragma line (with its `\n` or `\r\n`) when that
22
+ * name was the line's last.
23
+ *
24
+ * Its `declarationHint` hook tells a person exactly what to add: the line
25
+ * `# uses: curl`, on a line below the shebang, or as the first line of the
26
+ * file when there is no shebang. A name a pragma cannot express (one with
27
+ * whitespace, or starting with `#`) gets no hint.
28
+ *
29
+ * Bind it with `settings: { sh: { declarationConvention: commentPragma } }`.
30
+ *
31
+ * @public
32
+ */
33
+ export declare const commentPragma: Readonly<Required<DeclarationConvention>>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A header line (terminator excluded) that is blank or holds only a `#`
3
+ * comment. `[\s\S]` rather than `.`, so a stray `\r` or U+2028 inside a comment
4
+ * does not end the header early.
5
+ */
6
+ const HEADER_LINE = /^[ \t]*(?:#[\s\S]*)?$/u;
7
+ /**
8
+ * A pragma line; group 1 is everything after `uses:`: the whitespace-separated
9
+ * name list, then optionally a `#`-word and a trailing remark.
10
+ */
11
+ const PRAGMA_LINE = /^[ \t]*#[ \t]*uses:([\s\S]*)$/u;
12
+ /** One name in a pragma's name list. */
13
+ const PRAGMA_NAME = /\S+/gu;
14
+ /**
15
+ * Scans the header of `text` and returns its pragma lines in source order.
16
+ * Lines end at `\n`; a `\r` immediately before it belongs to the terminator
17
+ * (so CRLF files declare `jq`, never `jq\r`).
18
+ */
19
+ function readPragmaLines(text) {
20
+ const pragmas = [];
21
+ let lineStart = 0;
22
+ while (lineStart < text.length) {
23
+ const newline = text.indexOf('\n', lineStart);
24
+ const lineEnd = newline === -1 ? text.length : newline + 1;
25
+ const contentEnd = newline === -1
26
+ ? text.length
27
+ : newline > lineStart && text[newline - 1] === '\r'
28
+ ? newline - 1
29
+ : newline;
30
+ const content = text.slice(lineStart, contentEnd);
31
+ if (!HEADER_LINE.test(content)) {
32
+ break;
33
+ }
34
+ const pragma = PRAGMA_LINE.exec(content);
35
+ if (pragma) {
36
+ const list = pragma[1];
37
+ const listStart = lineStart + content.length - list.length;
38
+ const names = [];
39
+ for (const match of list.matchAll(PRAGMA_NAME)) {
40
+ // A `#`-word starts a trailing remark (`# uses: jq # for json`), not a name.
41
+ if (match[0].startsWith('#'))
42
+ break;
43
+ names.push({
44
+ kind: 'script',
45
+ name: match[0],
46
+ range: [listStart + match.index, listStart + match.index + match[0].length],
47
+ });
48
+ }
49
+ pragmas.push({ lineStart, contentEnd, lineEnd, names });
50
+ }
51
+ lineStart = lineEnd;
52
+ }
53
+ return pragmas;
54
+ }
55
+ /**
56
+ * The `[start, end)` range that removes `names[index]` from its pragma line
57
+ * while the line keeps other names: the name plus the whitespace separating it
58
+ * from its successor, or, for the last name, from its predecessor. Either way
59
+ * the remaining names keep single, original separators. Only called with
60
+ * `names.length > 1`; a line's only name is removed with its whole line.
61
+ */
62
+ function nameRemovalRange(names, index) {
63
+ const target = names[index];
64
+ return index < names.length - 1
65
+ ? [target.range[0], names[index + 1].range[0]]
66
+ : [names[index - 1].range[1], target.range[1]];
67
+ }
68
+ /**
69
+ * The `[start, end)` range that deletes `line` entirely, terminator included.
70
+ * The header's last line can lack a terminator (a file that is nothing but
71
+ * header); there the newline *before* the line goes instead, so the file does
72
+ * not end in a newly empty line.
73
+ */
74
+ function lineRemovalRange(text, line) {
75
+ if (line.lineEnd > line.contentEnd || line.lineStart === 0) {
76
+ return [line.lineStart, line.lineEnd];
77
+ }
78
+ const precedingTerminatorLength = text[line.lineStart - 2] === '\r' ? 2 : 1;
79
+ return [line.lineStart - precedingTerminatorLength, line.lineEnd];
80
+ }
81
+ function declaredCommands(sourceCode) {
82
+ return readPragmaLines(sourceCode.text).flatMap((line) => line.names);
83
+ }
84
+ function removeDeclaration(sourceCode, declared, fixer) {
85
+ const text = sourceCode.text;
86
+ for (const line of readPragmaLines(text)) {
87
+ const index = line.names.findIndex((candidate) => candidate.name === declared.name &&
88
+ candidate.range[0] === declared.range[0] &&
89
+ candidate.range[1] === declared.range[1]);
90
+ if (index === -1) {
91
+ continue;
92
+ }
93
+ return fixer.removeRange(line.names.length === 1 ? lineRemovalRange(text, line) : nameRemovalRange(line.names, index));
94
+ }
95
+ // Not one of this file's pragma declarations (for example, produced by a
96
+ // different convention): there is nothing this convention can safely edit.
97
+ return null;
98
+ }
99
+ /**
100
+ * A name a pragma line can declare: one whitespace-free word that does not
101
+ * start a trailing remark (`#`).
102
+ */
103
+ const DECLARABLE_NAME = /^[^\s#]\S*$/u;
104
+ /**
105
+ * Where to write a new pragma line so that it is in the header: directly
106
+ * below the shebang when the file has one (above it, the shebang would stop
107
+ * being line 1), otherwise as the first line. Either place is always a header
108
+ * line, whatever the rest of the header holds.
109
+ */
110
+ function declarationHint(command, sourceCode) {
111
+ if (!DECLARABLE_NAME.test(command))
112
+ return null;
113
+ const where = sourceCode.text.startsWith('#!')
114
+ ? 'on a line below the shebang'
115
+ : 'as the first line of the file';
116
+ return `Add \`# uses: ${command}\` ${where}.`;
117
+ }
118
+ /**
119
+ * The reference {@link DeclarationConvention}: a script declares the external
120
+ * commands it uses in `# uses:` comments in its header.
121
+ *
122
+ * ```sh
123
+ * #!/usr/bin/env bash
124
+ * # uses: jq curl
125
+ * # uses: git
126
+ * set -euo pipefail
127
+ * ```
128
+ *
129
+ * declares `jq`, `curl`, and `git`. The header is the run of blank and
130
+ * comment-only lines at the top of the file; a `# uses:` comment after the
131
+ * first command is not a declaration. Several pragma lines accumulate, names
132
+ * are whitespace-separated, and each declaration is `kind: 'script'` with a
133
+ * `range` that covers exactly that name. A word starting with `#` ends the list and begins a trailing
134
+ * remark: `# uses: jq # for json` declares only `jq`.
135
+ *
136
+ * Its `removeDeclaration` hook deletes one name and the whitespace that
137
+ * separated it, or the whole pragma line (with its `\n` or `\r\n`) when that
138
+ * name was the line's last.
139
+ *
140
+ * Its `declarationHint` hook tells a person exactly what to add: the line
141
+ * `# uses: curl`, on a line below the shebang, or as the first line of the
142
+ * file when there is no shebang. A name a pragma cannot express (one with
143
+ * whitespace, or starting with `#`) gets no hint.
144
+ *
145
+ * Bind it with `settings: { sh: { declarationConvention: commentPragma } }`.
146
+ *
147
+ * @public
148
+ */
149
+ export const commentPragma = Object.freeze({
150
+ declaredCommands,
151
+ removeDeclaration,
152
+ declarationHint,
153
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The declaration-convention extension point: how a project states which
3
+ * external commands a script depends on.
4
+ *
5
+ * Which commands a script *uses* is a fact this plugin can derive from the
6
+ * syntax tree. How a script *declares* the commands it intends to use is
7
+ * project policy — a header comment, a manifest variable, a sourced
8
+ * dependency file — and differs between consumers. This plugin therefore
9
+ * never hardcodes a declaration syntax. Rules that compare usage against
10
+ * declarations ask a {@link DeclarationConvention} for the declarations and,
11
+ * when they offer an autofix, ask the same convention to produce the edit,
12
+ * because only the convention knows its own syntax.
13
+ *
14
+ * A convention is bound by the consumer, through the shared
15
+ * `settings.sh.declarationConvention` setting (see
16
+ * `../rules/resolve-declaration-convention.ts`). This package ships exactly one
17
+ * reference implementation, `commentPragma` (`./comment-pragma.ts`).
18
+ */
19
+ import type { RuleTextEdit, RuleTextEditor } from '@eslint/core';
20
+ import type { ShellSourceCode } from '../language/shell-source-code.js';
21
+ /**
22
+ * The file ESLint is linting, as its rule context names it. A
23
+ * {@link DeclarationConvention} receives it so it can find declarations kept
24
+ * outside the script, such as a per-script sidecar file next to it.
25
+ *
26
+ * A rule's own `context` is a `LintedFile`, so a rule that calls
27
+ * `declaredCommands` passes `context`.
28
+ *
29
+ * @public
30
+ */
31
+ export interface LintedFile {
32
+ /**
33
+ * The name ESLint lints the code under (`context.filename`). For a code
34
+ * block a processor extracts from another file it is virtual
35
+ * (`README.md/0_0.sh`).
36
+ */
37
+ readonly filename: string;
38
+ /**
39
+ * The path on disk ESLint associates with the code
40
+ * (`context.physicalFilename`): the file it read, or the file a
41
+ * processor's code block came from. With `--stdin --stdin-filename`, it is
42
+ * the name given on the command line; the code came from standard input,
43
+ * and that file may not exist. Resolve sidecar paths from this one, and
44
+ * treat a missing sidecar as a normal case.
45
+ */
46
+ readonly physicalFilename: string;
47
+ /**
48
+ * ESLint's working directory (`context.cwd`). A relative
49
+ * {@link FileDeclaredCommand.path} is relative to it.
50
+ */
51
+ readonly cwd: string;
52
+ }
53
+ /**
54
+ * A declaration written in the linted script's own text: the only kind an
55
+ * ESLint fix can edit, because a fix edits only the file being linted.
56
+ *
57
+ * @public
58
+ */
59
+ export interface ScriptDeclaredCommand {
60
+ /** Says the declaration is in the script. */
61
+ readonly kind: 'script';
62
+ /** The declared command name, exactly as it would be invoked (`jq`, `docker-compose`). */
63
+ readonly name: string;
64
+ /**
65
+ * The `[start, end)` source range of this declaration, as UTF-16 offsets
66
+ * into `sourceCode.text` (the same coordinates as every node's `range`),
67
+ * with `0 <= start <= end <= sourceCode.text.length`. Rules report a
68
+ * declaration at this range, so it should cover the text that declares
69
+ * this one name and nothing else.
70
+ */
71
+ readonly range: readonly [number, number];
72
+ }
73
+ /**
74
+ * A declaration written in a file other than the linted script: a per-script
75
+ * sidecar, or a manifest several scripts share. A report about it names the
76
+ * file, and carries no fix, since a fix can only edit the linted script.
77
+ *
78
+ * @public
79
+ */
80
+ export interface FileDeclaredCommand {
81
+ /** Says the declaration is in another file. */
82
+ readonly kind: 'file';
83
+ /** The declared command name, exactly as it would be invoked. */
84
+ readonly name: string;
85
+ /**
86
+ * Where a person or a tool should look for the declaration: a path
87
+ * relative to {@link LintedFile.cwd}, or an absolute path. It must be a
88
+ * non-empty single line. Rules name it in their reports as given; they never
89
+ * read it.
90
+ */
91
+ readonly path: string;
92
+ /** The 1-based line of `path` that declares the command, when the convention knows it. */
93
+ readonly line?: number;
94
+ }
95
+ /**
96
+ * A declaration whose source can only be described in words: there is no
97
+ * range in the script and no path to point at, only a phrase that tells a
98
+ * person where it comes from, such as a list written in the ESLint config, a
99
+ * convention's own constant, or a snapshot of a tool registry. A report about
100
+ * it names the source by `description` and carries no fix.
101
+ *
102
+ * @public
103
+ */
104
+ export interface DescribedDeclaredCommand {
105
+ /** Says the declaration's source is described in words, by `description`. */
106
+ readonly kind: 'described';
107
+ /** The declared command name, exactly as it would be invoked. */
108
+ readonly name: string;
109
+ /**
110
+ * Names the source for a person who wants to change the declaration, as a
111
+ * phrase that reads after "declared in": "the web image list in
112
+ * eslint.config.js", "the tool registry snapshot of 2026-11-01". It must be a
113
+ * non-empty single line.
114
+ */
115
+ readonly description: string;
116
+ }
117
+ /**
118
+ * One command a script declares it depends on, as reported by a
119
+ * {@link DeclarationConvention}. Where the declaration lives is part of what
120
+ * it is, so it is one of three kinds, told apart by the required `kind`:
121
+ * written in the script ({@link ScriptDeclaredCommand}), in another file
122
+ * ({@link FileDeclaredCommand}), or in a source that can only be described in
123
+ * words ({@link DescribedDeclaredCommand}). Only the script kind has a `range`, so
124
+ * narrow on `kind` before reading it.
125
+ *
126
+ * @public
127
+ */
128
+ export type DeclaredCommand = ScriptDeclaredCommand | FileDeclaredCommand | DescribedDeclaredCommand;
129
+ /**
130
+ * How a project declares the external commands its shell scripts depend on.
131
+ *
132
+ * Implement this to teach the plugin a project's own declaration syntax, then
133
+ * bind it with `settings: { sh: { declarationConvention: myConvention } }`.
134
+ * Until a convention is bound, rules that consume one report nothing.
135
+ *
136
+ * Bind it through `settings`, not through a rule's options: ESLint copies
137
+ * rule options with `structuredClone`, which cannot copy functions. Define it
138
+ * as a plain object whose methods are its own properties, and bind it in one
139
+ * config entry: when two matching config entries both set it, ESLint merges
140
+ * the two objects property by property, which drops methods inherited from a
141
+ * class prototype and can mix methods from two different conventions.
142
+ *
143
+ * One convention object serves every file its config entry matches, so its
144
+ * answer must depend only on its arguments and on data it holds, never on
145
+ * which file it saw before. ESLint's `--cache` sees a convention's own
146
+ * enumerable data properties (they are part of the config it hashes) but not
147
+ * files the convention reads while linting.
148
+ *
149
+ * {@link commentPragma} is the reference implementation.
150
+ *
151
+ * @public
152
+ */
153
+ export interface DeclarationConvention {
154
+ /**
155
+ * Returns every command the linted file declares, in source order. A name
156
+ * declared twice appears twice, each with its own source. `file` names the
157
+ * file being linted, for a convention whose declarations live beside it;
158
+ * a convention that reads only `sourceCode` can leave it out.
159
+ */
160
+ declaredCommands(sourceCode: ShellSourceCode, file: LintedFile): DeclaredCommand[];
161
+ /**
162
+ * Optional edit hook: returns the fix that deletes the single stale
163
+ * declaration `declared` (an in-script entry previously returned by
164
+ * {@link DeclarationConvention.declaredCommands} for the same
165
+ * `sourceCode`), built with the rule's `fixer`. Returns `null` when that
166
+ * declaration cannot be removed safely.
167
+ *
168
+ * It is only ever called with a {@link ScriptDeclaredCommand}: a fix edits
169
+ * only the linted script, and no edit to the script removes a declaration
170
+ * kept anywhere else.
171
+ *
172
+ * A convention without this hook still reports stale declarations; those
173
+ * reports simply carry no autofix.
174
+ */
175
+ removeDeclaration?(sourceCode: ShellSourceCode, declared: ScriptDeclaredCommand, fixer: RuleTextEditor): RuleTextEdit | RuleTextEdit[] | null;
176
+ /**
177
+ * Optional hint hook: returns one short sentence that tells a person how to
178
+ * declare `command`, with text they can copy. {@link commentPragma}'s hint says to
179
+ * add `# uses: curl` on a line below the shebang.
180
+ * `sh/no-undeclared-commands` appends it to its report. Following the
181
+ * sentence literally must make that report go away.
182
+ *
183
+ * Return `null` when this convention cannot declare `command` as written
184
+ * (for example, a name its syntax cannot express). The report then falls
185
+ * back to a generic sentence, as it does for a convention without this
186
+ * hook. Anything other than `null` must be a non-empty single-line string.
187
+ */
188
+ declarationHint?(command: string, sourceCode: ShellSourceCode, file: LintedFile): string | null;
189
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The one path by which a rule reads a bound convention's declarations for
3
+ * the file it is linting. The built-in consumers (`sh/no-undeclared-commands`,
4
+ * `sh/no-unused-declarations`) and a pack's own rules all call
5
+ * {@link readDeclaredCommands}, so they hand the convention the same
6
+ * {@link LintedFile} and trust only declarations that passed the same checks.
7
+ *
8
+ * The checks exist because a declaration's kind decides where a report goes
9
+ * and whether a fix is offered. A declaration of an unknown kind, one missing
10
+ * its kind's fields, or a script range outside the text would otherwise be
11
+ * reported at a made-up location, crash deep inside a rule, or be silently
12
+ * accepted by a rule that only reads names. Failing with the declaration and
13
+ * the setting in the message makes the convention's bug the thing the user
14
+ * sees.
15
+ */
16
+ import type { ShellSourceCode } from '../language/shell-source-code.js';
17
+ import type { DeclarationConvention, DeclaredCommand, LintedFile } from './declaration-convention.js';
18
+ /**
19
+ * What {@link readDeclaredCommands} reads from a rule's context: the linted
20
+ * file and its source code. Any shell rule's `context` satisfies it.
21
+ *
22
+ * @public
23
+ */
24
+ export interface DeclarationReadContext extends LintedFile {
25
+ /** The source code of the file being linted. */
26
+ readonly sourceCode: ShellSourceCode;
27
+ }
28
+ /**
29
+ * The only view of the linted file a convention's hooks receive: `filename`,
30
+ * `physicalFilename` and `cwd`, frozen. A rule's `context` must never reach a
31
+ * hook, because it would let a convention report, read options or reach other
32
+ * rule state.
33
+ *
34
+ * @internal
35
+ */
36
+ export declare function toLintedFile(context: LintedFile): LintedFile;
37
+ /**
38
+ * Returns the declarations `convention` makes for the file `context` is
39
+ * linting, after checking each one against its kind. Call it from a rule's
40
+ * listener with the convention `resolveDeclarationConvention` returned and
41
+ * the rule's own `context`.
42
+ *
43
+ * The convention receives a frozen {@link LintedFile} holding only
44
+ * `filename`, `physicalFilename` and `cwd`, never the whole context, so it
45
+ * sees which file it is reading and nothing it could report through.
46
+ *
47
+ * Throws an `Error` that names the setting, starting
48
+ * `Invalid settings.sh.declarationConvention:`, and shows the offending value when
49
+ * `declaredCommands` does not return an array, or returns a declaration with
50
+ * a missing or unknown `kind`, without its kind's fields, or with a `range`
51
+ * outside the linted text.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const convention = resolveDeclarationConvention(context);
56
+ * if (convention === null) return {};
57
+ * return {
58
+ * File() {
59
+ * for (const declared of readDeclaredCommands(convention, context)) {
60
+ * if (declared.kind === 'script') {
61
+ * // declared.range is an offset pair into context.sourceCode.text
62
+ * }
63
+ * }
64
+ * },
65
+ * };
66
+ * ```
67
+ *
68
+ * @public
69
+ */
70
+ export declare function readDeclaredCommands(convention: DeclarationConvention, context: DeclarationReadContext): DeclaredCommand[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The binding a validation error names. A convention can only be bound in
3
+ * settings in practice: ESLint copies rule options with `structuredClone`,
4
+ * which rejects an object with methods.
5
+ */
6
+ const BINDING = 'settings.sh.declarationConvention';
7
+ /**
8
+ * The only view of the linted file a convention's hooks receive: `filename`,
9
+ * `physicalFilename` and `cwd`, frozen. A rule's `context` must never reach a
10
+ * hook, because it would let a convention report, read options or reach other
11
+ * rule state.
12
+ *
13
+ * @internal
14
+ */
15
+ export function toLintedFile(context) {
16
+ return Object.freeze({
17
+ filename: context.filename,
18
+ physicalFilename: context.physicalFilename,
19
+ cwd: context.cwd,
20
+ });
21
+ }
22
+ /** `value` is a non-empty string with no line break or NUL, fit to name a source in one message line. */
23
+ function isSingleLine(value) {
24
+ return typeof value === 'string' && value !== '' && !/[\r\n\0]/u.test(value);
25
+ }
26
+ /**
27
+ * Why `value` is not a valid {@link DeclaredCommand} for a file whose text is
28
+ * `textLength` UTF-16 units long, or `null` when it is. Each kind is held to
29
+ * exactly the fields its reports need.
30
+ */
31
+ function invalidReason(value, textLength) {
32
+ if (typeof value !== 'object' || value === null)
33
+ return 'it is not an object';
34
+ const declared = value;
35
+ if (typeof declared.name !== 'string' || declared.name === '') {
36
+ return '`name` must be a non-empty string';
37
+ }
38
+ switch (declared.kind) {
39
+ case 'script': {
40
+ const { range } = declared;
41
+ const valid = Array.isArray(range) &&
42
+ range.length === 2 &&
43
+ Number.isInteger(range[0]) &&
44
+ Number.isInteger(range[1]) &&
45
+ 0 <= range[0] &&
46
+ range[0] <= range[1] &&
47
+ range[1] <= textLength;
48
+ return valid
49
+ ? null
50
+ : `a script declaration needs \`range\`: two offsets [start, end) with 0 <= start <= end <= ${String(textLength)}, the length of the linted text`;
51
+ }
52
+ case 'file':
53
+ if (!isSingleLine(declared.path)) {
54
+ return 'a file declaration needs `path`: a non-empty single-line path, relative to `cwd` or absolute';
55
+ }
56
+ return declared.line === undefined ||
57
+ (Number.isInteger(declared.line) && declared.line >= 1)
58
+ ? null
59
+ : 'the optional `line` of a file declaration must be a positive integer';
60
+ case 'described':
61
+ return isSingleLine(declared.description)
62
+ ? null
63
+ : 'a described declaration needs `description`: a non-empty single line';
64
+ case undefined:
65
+ return "`kind` is missing: it must be 'script', 'file' or 'described'";
66
+ default:
67
+ return `\`kind\` must be 'script', 'file' or 'described', not ${describe(declared.kind)}`;
68
+ }
69
+ }
70
+ /** A short rendering of a value for an error message. */
71
+ function describe(value) {
72
+ try {
73
+ // `JSON.stringify` returns `undefined` for a function or `undefined`.
74
+ const text = JSON.stringify(value);
75
+ return text ?? String(value);
76
+ }
77
+ catch {
78
+ return String(value);
79
+ }
80
+ }
81
+ /**
82
+ * Returns the declarations `convention` makes for the file `context` is
83
+ * linting, after checking each one against its kind. Call it from a rule's
84
+ * listener with the convention `resolveDeclarationConvention` returned and
85
+ * the rule's own `context`.
86
+ *
87
+ * The convention receives a frozen {@link LintedFile} holding only
88
+ * `filename`, `physicalFilename` and `cwd`, never the whole context, so it
89
+ * sees which file it is reading and nothing it could report through.
90
+ *
91
+ * Throws an `Error` that names the setting, starting
92
+ * `Invalid settings.sh.declarationConvention:`, and shows the offending value when
93
+ * `declaredCommands` does not return an array, or returns a declaration with
94
+ * a missing or unknown `kind`, without its kind's fields, or with a `range`
95
+ * outside the linted text.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const convention = resolveDeclarationConvention(context);
100
+ * if (convention === null) return {};
101
+ * return {
102
+ * File() {
103
+ * for (const declared of readDeclaredCommands(convention, context)) {
104
+ * if (declared.kind === 'script') {
105
+ * // declared.range is an offset pair into context.sourceCode.text
106
+ * }
107
+ * }
108
+ * },
109
+ * };
110
+ * ```
111
+ *
112
+ * @public
113
+ */
114
+ export function readDeclaredCommands(convention, context) {
115
+ const { sourceCode } = context;
116
+ const file = toLintedFile(context);
117
+ const declarations = convention.declaredCommands(sourceCode, file);
118
+ if (!Array.isArray(declarations)) {
119
+ throw new Error(`Invalid ${BINDING}: \`declaredCommands\` must return an array, but returned ${describe(declarations)}.`);
120
+ }
121
+ for (const declared of declarations) {
122
+ const reason = invalidReason(declared, sourceCode.text.length);
123
+ if (reason !== null) {
124
+ throw new Error(`Invalid ${BINDING}: \`declaredCommands\` returned the declaration ${describe(declared)}, but ${reason}.`);
125
+ }
126
+ }
127
+ return declarations;
128
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The faithful shell analysis surface (`@eslint-sh/eslint-plugin/syntax/analyze`): the complete
3
+ * public surface of `sh-ast/analyze`, re-exported unchanged from the very `sh-ast` instance this
4
+ * plugin's language parses with.
5
+ *
6
+ * It provides `enumerateCommands`, `resolveArgv0` (whose `chainIndices` locate a resolved command's
7
+ * arguments in its site's `argv`), `DEFAULT_TRANSPARENT_WRAPPERS`, `WrapperSpec`, the raw
8
+ * `resolveWord(Node)`, and the named operator constants with `rawOperator` for comparing and
9
+ * constructing the operator values those records carry. All of it works on the faithful records
10
+ * from `@eslint-sh/eslint-plugin/syntax`. The plugin root's `resolveWord` takes a lint-view
11
+ * `ShellNode` instead; the two share a name but not an input contract, so this subpath is kept
12
+ * separate from the root.
13
+ *
14
+ * Like `@eslint-sh/eslint-plugin/syntax`, it tracks the plugin's pinned `sh-ast` minor and never
15
+ * wraps or renames anything.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+
21
+ export * from "sh-ast/analyze";
22
+
23
+ export { }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The faithful shell syntax surface (`@eslint-sh/eslint-plugin/syntax`): the complete public
3
+ * surface of `sh-ast`'s root entry point, re-exported unchanged from the very `sh-ast` instance
4
+ * this plugin's language parses with.
5
+ *
6
+ * Use it when a custom rule edits or analyzes the parser's own records rather than the plugin's
7
+ * lint view: `walk`, `parseSync`, `printSync` and `PrintOptions`, the record types (`Node`,
8
+ * `Word`, `CallExpr`, `SyntaxPos`, `GoString`, …), and the operator and language constants.
9
+ * Importing them from here, instead of installing `sh-ast` separately, means a rule always gets
10
+ * the same `sh-ast` version and module instance as the language that produced its syntax. Under
11
+ * 0.x a different `sh-ast` minor is a breaking change, and records from one copy are not
12
+ * guaranteed to be accepted by another.
13
+ *
14
+ * This entry point tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a
15
+ * minor release of this plugin. It never wraps or renames anything: every name has `sh-ast`'s
16
+ * own contract. The plugin root (`@eslint-sh/eslint-plugin`) keeps the view-aware helpers such
17
+ * as `resolveWord(ShellNode)`, which differ from the raw helpers of the same name in
18
+ * `@eslint-sh/eslint-plugin/syntax/analyze`. That is why these names live on their own subpath
19
+ * and are not merged into the root.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+
25
+ export * from "sh-ast";
26
+
27
+ export { }