@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,478 @@
1
+ # Declaration conventions
2
+
3
+ A script that calls `jq` and `curl` depends on both being installed. Many projects want each
4
+ script to _declare_ the external commands it uses, so that a reviewer, an installer, or a lint
5
+ rule can compare what a script says it needs with what it actually runs.
6
+
7
+ How a script declares its dependencies is a project decision: a header comment, a variable, a
8
+ sourced manifest. This plugin does not pick one for you. It defines an extension point, the
9
+ `DeclarationConvention` interface, and ships one reference implementation, `commentPragma`. Rules
10
+ that check declarations ask the bound convention which commands a script declares and, when they
11
+ offer an autofix, ask the same convention to edit the declaration, because only the convention
12
+ knows its own syntax.
13
+
14
+ **With no convention bound, rules that consume one report nothing.** Binding a convention is how
15
+ you opt in.
16
+
17
+ ## The interface
18
+
19
+ ```ts
20
+ import type { RuleTextEdit, RuleTextEditor } from '@eslint/core';
21
+ import type { ShellSourceCode } from '@eslint-sh/eslint-plugin';
22
+
23
+ /** The file being linted, as ESLint's rule context names it. */
24
+ interface LintedFile {
25
+ readonly filename: string; // virtual for a processor's code block (README.md/0_0.sh)
26
+ readonly physicalFilename: string; // the path on disk ESLint associates with the code
27
+ readonly cwd: string; // a relative FileDeclaredCommand.path is relative to this
28
+ }
29
+
30
+ /** Written in the script. */
31
+ interface ScriptDeclaredCommand {
32
+ readonly kind: 'script';
33
+ readonly name: string;
34
+ /** [start, end) offsets into sourceCode.text, covering just this declaration. */
35
+ readonly range: readonly [number, number];
36
+ }
37
+
38
+ /** Written in another file, such as a sidecar next to the script. */
39
+ interface FileDeclaredCommand {
40
+ readonly kind: 'file';
41
+ readonly name: string;
42
+ readonly path: string; // where to look: relative to cwd, or absolute
43
+ readonly line?: number; // 1-based, when known
44
+ }
45
+
46
+ /** From a source described in words: a list in eslint.config.js, a registry snapshot. */
47
+ interface DescribedDeclaredCommand {
48
+ readonly kind: 'described';
49
+ readonly name: string;
50
+ readonly description: string; // "the tool registry snapshot of 2026-11-01"
51
+ }
52
+
53
+ type DeclaredCommand = ScriptDeclaredCommand | FileDeclaredCommand | DescribedDeclaredCommand;
54
+
55
+ interface DeclarationConvention {
56
+ /** Every command the linted file declares, in source order. */
57
+ declaredCommands(sourceCode: ShellSourceCode, file: LintedFile): DeclaredCommand[];
58
+ /** Optional: the fix that deletes one stale in-script declaration, or null. */
59
+ removeDeclaration?(
60
+ sourceCode: ShellSourceCode,
61
+ declared: ScriptDeclaredCommand,
62
+ fixer: RuleTextEditor,
63
+ ): RuleTextEdit | RuleTextEdit[] | null;
64
+ /** Optional: one sentence telling a person how to declare `command`, or null. */
65
+ declarationHint?(command: string, sourceCode: ShellSourceCode, file: LintedFile): string | null;
66
+ }
67
+ ```
68
+
69
+ - `declaredCommands` is required. A name declared twice appears twice, each with its own source.
70
+ Its second argument says which file is being linted; a convention that only reads the script's
71
+ text can leave it out.
72
+ - **Where a declaration lives** is one of three kinds, told apart by `kind`, which every
73
+ declaration must carry:
74
+ - **In the script** (`kind: 'script'`): `range` uses the same coordinates as every node's
75
+ `range`, UTF-16 offsets into `sourceCode.text`, and must lie within the text. A rule reports
76
+ the declaration there, so cover exactly the text that declares this one name. Use
77
+ `sourceCode.getLocFromIndex(offset)` to turn an offset into a line and column.
78
+ - **In another file** (`kind: 'file'`): `path` says where a person or a tool should look for the
79
+ declaration. It is relative to `file.cwd` unless it is absolute, and it must be a non-empty
80
+ single line. Add `line` (1-based) when you know it. Rules name the path in their reports as
81
+ given; they never read it.
82
+ - **From a source described in words** (`kind: 'described'`): a declaration with no range in
83
+ the script and no file to point at, whose source can only be described, such as a list written
84
+ in `eslint.config.js`, a constant in the convention, or a snapshot of a tool registry.
85
+ `description` holds those words: it names the source for a person who wants to change the
86
+ declaration, as a phrase that reads after "declared in", such as
87
+ `` the `web` image list in eslint.config.js `` or `the tool registry snapshot of 2026-11-01`.
88
+ It must be a non-empty single line.
89
+ - `removeDeclaration` is optional. It receives one in-script entry that `declaredCommands` returned
90
+ for the same `sourceCode`, plus the rule's `fixer`, and returns the edit that deletes that
91
+ declaration. Return `null` when it can't be removed safely. A convention without this hook still
92
+ gets its stale declarations reported; the reports just carry no autofix. It is never called for
93
+ a declaration kept anywhere else: a fix can only edit the script being linted.
94
+ - `declarationHint` is optional. `sh/no-undeclared-commands` appends what it returns to its
95
+ report, so write one short sentence with text a person can copy, such as
96
+ ``Add `# uses: curl` on a line below the shebang.`` Following it literally must clear the
97
+ report. Return `null` for a name your syntax can't express. Without the hook, or on `null`,
98
+ the report ends with the generic `Declare it with the project's declaration convention.`
99
+ Anything else must be a non-empty single-line string, or linting stops with an error.
100
+ - A declaration with a missing or unknown `kind`, without its kind's fields, or with a `range`
101
+ outside the text stops linting with an error that starts
102
+ `Invalid settings.sh.declarationConvention:` and shows the declaration.
103
+
104
+ To switch on `kind` exhaustively, end with a `never` default. A future kind then fails to compile
105
+ instead of slipping through:
106
+
107
+ ```ts
108
+ function whereToLook(declared: DeclaredCommand): string {
109
+ switch (declared.kind) {
110
+ case 'script':
111
+ return `offset ${String(declared.range[0])}`;
112
+ case 'file':
113
+ return declared.path;
114
+ case 'described':
115
+ return declared.description;
116
+ default: {
117
+ const unreachable: never = declared;
118
+ return unreachable;
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ These types and `LintedFile` are exported from `@eslint-sh/eslint-plugin`.
125
+
126
+ ## The reference convention: `commentPragma`
127
+
128
+ `commentPragma` reads `# uses:` comments from a script's header:
129
+
130
+ ```sh
131
+ #!/usr/bin/env bash
132
+ # Publishes the build manifest.
133
+ # uses: jq curl
134
+ # uses: git
135
+ set -euo pipefail
136
+ ```
137
+
138
+ This script declares `jq`, `curl`, and `git`.
139
+
140
+ - **Header only.** The header is the run of blank and comment-only lines at the top of the file,
141
+ shebang included. It ends at the first line with anything else on it. A `# uses:` comment after
142
+ the first command, or inside a heredoc body, is not a declaration.
143
+ - **Pragma lines.** A header comment whose text starts with `uses:` (lowercase) is a pragma.
144
+ Whitespace is allowed before the `#` and between `#` and `uses:`.
145
+ - **Names.** The rest of the line is a whitespace-separated list of names. Several pragma lines
146
+ accumulate. Each declaration is `kind: 'script'`, and its `range` covers exactly that name.
147
+ - **Trailing remarks.** The list ends at the first word that starts with `#`. Everything from
148
+ there to the end of the line is a remark, not a name: `# uses: jq curl # for the release API`
149
+ declares only `jq` and `curl`. A `#` later in a word does not end the list (`c#sharp` is a
150
+ name).
151
+ - **Edit hook.** `removeDeclaration` deletes one name and the whitespace that separated it:
152
+ removing `curl` from `# uses: jq curl` leaves `# uses: jq`, and from
153
+ `# uses: jq curl # for the release API` leaves `# uses: jq # for the release API`. When the name
154
+ was the line's last, the whole line goes, remark included, with its `\n` or `\r\n` line
155
+ ending.
156
+ - **Hint.** `declarationHint` gives `sh/no-undeclared-commands` its fix-it sentence:
157
+ ``Add `# uses: curl` on a line below the shebang.``, or ``… as the first line of the file.``
158
+ when there is no shebang (a line above the shebang would break it). A name with whitespace, or
159
+ one starting with `#`, can't be written in a pragma and gets no hint.
160
+
161
+ Bind it in your flat config:
162
+
163
+ ```js
164
+ import sh, { commentPragma } from '@eslint-sh/eslint-plugin';
165
+
166
+ export default [
167
+ {
168
+ files: ['**/*.sh', '**/*.bash'],
169
+ plugins: { sh },
170
+ language: 'sh/sh',
171
+ settings: { sh: { declarationConvention: commentPragma } },
172
+ },
173
+ ];
174
+ ```
175
+
176
+ ## Binding a convention
177
+
178
+ Bind a convention with the shared setting `settings.sh.declarationConvention`. Every rule that
179
+ consumes a convention reads it from there.
180
+
181
+ - **Use `settings`, not a rule's options.** ESLint copies each rule's options with
182
+ `structuredClone`, which can't copy functions, so ESLint rejects a convention passed as a rule
183
+ option with a "could not be cloned" configuration error.
184
+ - **Define the convention as a plain object** whose methods are its own properties, as both
185
+ `commentPragma` and the example below do.
186
+ - **Bind it in one config entry.** When two config entries that match the same file both set
187
+ `settings.sh.declarationConvention`, ESLint merges the two objects property by property. That
188
+ drops methods inherited from a class prototype and can mix methods from two different
189
+ conventions.
190
+ - **A value that isn't a convention is a configuration error.** If the setting holds anything
191
+ without a `declaredCommands` method, linting stops with an error that names the setting and
192
+ says what it got instead, starting `Invalid settings.sh.declarationConvention: expected a
193
+ declaration convention`. A `removeDeclaration` or `declarationHint` that is present but not a
194
+ function is rejected the same way.
195
+
196
+ ## Writing your own convention
197
+
198
+ Suppose your project declares each dependency on its own `# @dep <name>` line. This complete
199
+ convention reads those lines and can delete one. It is the shipped file
200
+ [`examples/dep-line-convention.mjs`](./examples/dep-line-convention.mjs), and
201
+ this package's test suite runs it in CI through a real ESLint `Linter`, so this
202
+ page can't drift from working code.
203
+
204
+ ```mjs
205
+ /**
206
+ * A custom declaration convention: each dependency is declared on its own
207
+ * `# @dep <name>` line. From `../declaration-conventions.md`, and run in CI.
208
+ */
209
+ const depLines = {
210
+ declaredCommands(sourceCode) {
211
+ const declared = [];
212
+ for (const match of sourceCode.text.matchAll(/^# @dep (\S+)[ \t]*$/gmu)) {
213
+ const start = match.index + '# @dep '.length;
214
+ declared.push({ kind: 'script', name: match[1], range: [start, start + match[1].length] });
215
+ }
216
+ return declared;
217
+ },
218
+ removeDeclaration(sourceCode, declared, fixer) {
219
+ const { text } = sourceCode;
220
+ const lineStart = text.lastIndexOf('\n', declared.range[0]) + 1;
221
+ const newline = text.indexOf('\n', declared.range[1]);
222
+ return fixer.removeRange([lineStart, newline === -1 ? text.length : newline + 1]);
223
+ },
224
+ };
225
+
226
+ export default depLines;
227
+ ```
228
+
229
+ - `declaredCommands` computes each range from the match's offset into `sourceCode.text`, so the
230
+ range covers only the name, not the `# @dep ` prefix.
231
+ - `removeDeclaration` deletes the declaration's whole line, including its line ending. The
232
+ declaration's own `range` tells it which line.
233
+ - This example scans the whole file, so a `# @dep` line inside a heredoc body would count too.
234
+ `commentPragma` avoids that by reading only the header. Scope your own convention the same way
235
+ if your scripts carry such text.
236
+
237
+ Bind it like any other convention:
238
+
239
+ ```js
240
+ import sh from '@eslint-sh/eslint-plugin';
241
+ import depLines from './dep-line-convention.mjs';
242
+
243
+ export default [
244
+ {
245
+ files: ['**/*.sh', '**/*.bash'],
246
+ plugins: { sh },
247
+ language: 'sh/sh',
248
+ settings: { sh: { declarationConvention: depLines } },
249
+ },
250
+ ];
251
+ ```
252
+
253
+ ## Declarations outside the script
254
+
255
+ Some projects keep a script's dependencies next to it rather than in it: `deploy.sh` beside a
256
+ `deploy.commands` file, one name per line. A convention can say so. It uses the `file` argument
257
+ to find the sidecar of the script being linted, and returns `kind: 'file'` declarations that name
258
+ the sidecar and the line. This is the shipped file
259
+ [`examples/sidecar-convention.mjs`](./examples/sidecar-convention.mjs), and
260
+ this package's test suite runs it in CI on real files.
261
+
262
+ ```mjs
263
+ /**
264
+ * A declaration convention whose declarations live beside each script:
265
+ * `deploy.sh` declares its commands in `deploy.commands`, one name per line.
266
+ * It reads the sidecar while linting. From `../declaration-conventions.md`,
267
+ * and run in CI.
268
+ */
269
+ import { readFileSync } from 'node:fs';
270
+ import { basename, dirname, extname, join, relative } from 'node:path';
271
+
272
+ /** `deploy.sh` → `deploy.commands`, next to the script on disk. */
273
+ export function sidecarPathFor(script) {
274
+ return join(dirname(script), `${basename(script, extname(script))}.commands`);
275
+ }
276
+
277
+ /** One file declaration per non-blank, non-`#` line of `text`. */
278
+ export function parseSidecar(text, shownPath) {
279
+ return text.split(/\r?\n/u).flatMap((line, index) => {
280
+ const name = line.trim();
281
+ if (name === '' || name.startsWith('#')) return [];
282
+ return [{ kind: 'file', name, path: shownPath, line: index + 1 }];
283
+ });
284
+ }
285
+
286
+ /** Reads the linted script's own sidecar each time it is asked. */
287
+ const sidecarCommands = {
288
+ declaredCommands(sourceCode, file) {
289
+ const path = sidecarPathFor(file.physicalFilename);
290
+ let text;
291
+ try {
292
+ text = readFileSync(path, 'utf8');
293
+ } catch {
294
+ return []; // No sidecar: the script declares nothing.
295
+ }
296
+ return parseSidecar(text, relative(file.cwd, path));
297
+ },
298
+ };
299
+
300
+ export default sidecarCommands;
301
+ ```
302
+
303
+ - It resolves the sidecar from `file.physicalFilename`, the path on disk ESLint associates with
304
+ the code. For a shell block that a processor extracts from Markdown, that is the Markdown file,
305
+ and `file.filename` is a virtual name like `README.md/0_0.sh`. With
306
+ `eslint --stdin --stdin-filename scripts/new.sh`, it is the name given on the command line: the
307
+ code came from standard input, and `scripts/new.sh` may not exist. That is why a missing
308
+ sidecar is a normal case here, not an error.
309
+ - The path it returns is relative to `file.cwd`, so it says where to look from where ESLint runs.
310
+ - With `git` on line 3 of `scripts/deploy.commands` and never run, `sh/no-unused-declarations`
311
+ reports: `'git' is declared in scripts/deploy.commands:3 but this script never uses it. Remove
312
+ it there so the script's stated dependencies match what it runs.` ESLint can only report inside
313
+ the file it lints, so the report sits at line 1, column 1, with no end, and has no fix. The
314
+ message says where to look.
315
+ - `sh/no-undeclared-commands` counts every kind of declaration the same way. A script with no
316
+ sidecar declares nothing, so each command it runs is reported as undeclared.
317
+
318
+ ### Silencing a report about a declaration outside the script
319
+
320
+ A report about a declaration in another file or a described source sits at line 1, column 1. In a
321
+ script that starts with a shebang, no comment can reach that position. `#!/bin/sh` must be the
322
+ whole first line, so `#!/bin/sh # eslint-disable-line …` is not a directive. A directive on line
323
+ 2, whether `# eslint-disable-next-line …` or `# eslint-disable …`, covers only line 3 or line 2
324
+ onward, and ESLint reports it as unused. (In a script with no shebang, a `# eslint-disable …` or
325
+ `# eslint-disable-line …` comment on line 1 does work.) The ways out:
326
+
327
+ - **Fix the declaration.** Remove the stale name from the file or source the message names. That
328
+ is what the report asks for, and the report goes away.
329
+ - **A `files` override.** Turn the rule off for the scripts concerned in your config:
330
+
331
+ ```js
332
+ { files: ['scripts/legacy/*.sh'], rules: { 'sh/no-unused-declarations': 'off' } }
333
+ ```
334
+
335
+ - **Bulk suppressions.** `eslint --suppress-rule sh/no-unused-declarations` records how many
336
+ reports each file has in `eslint-suppressions.json`. Later runs hide them while a file has no
337
+ more than that many, and show them all once it has more.
338
+
339
+ This package's test suite covers all three.
340
+
341
+ ### Sidecars and `eslint --cache`
342
+
343
+ One convention object serves every file its config entry matches. Keep its answer a function of
344
+ `sourceCode`, `file` and the data it holds, never of which file it saw before, and two files will
345
+ never see each other's declarations.
346
+
347
+ **ESLint's cache does not know your convention reads files.** `eslint --cache` reuses a file's
348
+ last result while the file and the hash of its config are unchanged. The convention above reads
349
+ the sidecar while linting, so after you edit only a sidecar, `eslint --cache` reports the old
350
+ result. After editing a sidecar, delete the cache file your runs use: the one `--cache-location`
351
+ names, or `.eslintcache` in the working directory by default. A single run without `--cache` gives
352
+ a correct result and deletes `.eslintcache`, but it leaves a file at another `--cache-location`
353
+ in place, so the next `eslint --cache --cache-location …` run is stale again.
354
+
355
+ To make sidecar edits invalidate the cache, read the sidecars when the config loads and keep their
356
+ text as plain data on the convention. ESLint's cache hashes the config, `settings` included, as
357
+ JSON: a convention's own data properties are part of that hash, and its functions and closures are
358
+ not. Editing any sidecar then re-lints every file in that config entry. This is the shipped file
359
+ [`examples/sidecar-snapshot-convention.mjs`](./examples/sidecar-snapshot-convention.mjs):
360
+
361
+ ```mjs
362
+ /**
363
+ * The sidecar convention, reading every sidecar under `root` once, when the
364
+ * ESLint config loads, and keeping their text as plain data, which ESLint's
365
+ * `--cache` sees. From `../declaration-conventions.md`, and run in CI.
366
+ */
367
+ import { readFileSync, readdirSync } from 'node:fs';
368
+ import { join, relative } from 'node:path';
369
+ import { parseSidecar, sidecarPathFor } from './sidecar-convention.mjs';
370
+
371
+ /** A convention over the `*.commands` files under `root`, as they are now. */
372
+ export function sidecarSnapshot(root) {
373
+ const sidecars = {};
374
+ for (const entry of readdirSync(root, { recursive: true })) {
375
+ if (entry.endsWith('.commands')) {
376
+ sidecars[entry] = readFileSync(join(root, entry), 'utf8');
377
+ }
378
+ }
379
+ return {
380
+ sidecars, // data, not a closure: part of the config ESLint's cache hashes
381
+ declaredCommands(sourceCode, file) {
382
+ const path = sidecarPathFor(file.physicalFilename);
383
+ const text = sidecars[relative(root, path)];
384
+ return text === undefined ? [] : parseSidecar(text, relative(file.cwd, path));
385
+ },
386
+ };
387
+ }
388
+ ```
389
+
390
+ Bind it with `declarationConvention: sidecarSnapshot(import.meta.dirname)` in `eslint.config.js`.
391
+ The trade-off: the snapshot is taken when the config loads, so a long-running integration, such as
392
+ an editor's ESLint server, keeps using it until it reloads the config. Both behaviors, the stale
393
+ cache and the snapshot that invalidates it, are tested in this package's test suite.
394
+
395
+ ## Reading the convention in your own rule
396
+
397
+ `sh/no-undeclared-commands` and `sh/no-unused-declarations` are the built-in rules that read the
398
+ bound convention. A rule of your own reads it through the same two functions from
399
+ `@eslint-sh/eslint-plugin`, so it gets the same behavior without re-implementing it:
400
+
401
+ - **`resolveDeclarationConvention(context)`**, from `create()`, returns the convention from the
402
+ rule's own `declarationConvention` option, else from `settings.sh.declarationConvention`, else
403
+ `null`. With `null`, return no listeners: there is nothing to check. A bound value that isn't a
404
+ convention throws the configuration error the built-in rules throw, naming the setting:
405
+ `Invalid settings.sh.declarationConvention: expected a declaration convention`, and so on.
406
+ - **`readDeclaredCommands(convention, context)`**, from a listener, returns the declarations for
407
+ the file `context` is linting. It hands the convention the `LintedFile`, so a sidecar
408
+ convention works in your rule too, and it checks every declaration exactly as the built-in
409
+ rules do. A missing or unknown `kind`, a missing field, or a `range` outside the text throws
410
+ `Invalid settings.sh.declarationConvention: …`. Don't call `convention.declaredCommands` directly: you would get unchecked
411
+ values.
412
+ - Only in-script declarations have a `range`. Check `kind` before reading it: a declaration with
413
+ `kind` `'file'` or `'described'` lives somewhere your rule can't point at.
414
+
415
+ This rule reports a declaration that is out of alphabetical order, under whichever convention the
416
+ project binds. It is the shipped file
417
+ [`examples/sorted-declarations-rule.mjs`](./examples/sorted-declarations-rule.mjs), and
418
+ this package's test suite runs it in CI with both `commentPragma` and the
419
+ `# @dep` convention above.
420
+
421
+ ```mjs
422
+ /**
423
+ * A rule of your own that reads the bound declaration convention: it reports
424
+ * a declaration that is out of alphabetical order. From
425
+ * `../declaration-conventions.md`, and run in CI.
426
+ */
427
+ import {
428
+ defineShellRule,
429
+ readDeclaredCommands,
430
+ resolveDeclarationConvention,
431
+ } from '@eslint-sh/eslint-plugin';
432
+
433
+ const sortedDeclarations = defineShellRule({
434
+ meta: {
435
+ type: 'suggestion',
436
+ schema: [],
437
+ messages: { unsorted: 'Declare `{{name}}` before `{{previous}}`.' },
438
+ },
439
+ create(context) {
440
+ // The bound convention, checked exactly as the built-in rules check it;
441
+ // `null` when none is bound, and then the rule has nothing to check.
442
+ const convention = resolveDeclarationConvention(context);
443
+ if (convention === null) {
444
+ return {};
445
+ }
446
+ return {
447
+ File() {
448
+ const { sourceCode } = context;
449
+ // The file's declarations, checked as the built-in rules check them.
450
+ // Only those written in the script have a range to report at.
451
+ const declared = readDeclaredCommands(convention, context).filter(
452
+ (each) => each.kind === 'script',
453
+ );
454
+ for (let i = 1; i < declared.length; i++) {
455
+ const previous = declared[i - 1].name;
456
+ const { name, range } = declared[i];
457
+ if (name < previous) {
458
+ context.report({
459
+ loc: {
460
+ start: sourceCode.getLocFromIndex(range[0]),
461
+ end: sourceCode.getLocFromIndex(range[1]),
462
+ },
463
+ messageId: 'unsorted',
464
+ data: { name, previous },
465
+ });
466
+ }
467
+ }
468
+ },
469
+ };
470
+ },
471
+ });
472
+
473
+ export default sortedDeclarations;
474
+ ```
475
+
476
+ With `commentPragma` bound, `# uses: jq curl` reports `curl`: `` Declare `curl` before `jq`. ``
477
+ `defineShellRule` and writing rules in general are covered in the [custom-rule
478
+ tutorial](./custom-rule-tutorial.md).
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A rule pack that requires infrastructure CLIs to be run through its
3
+ * `audit-run` wrapper. From `../wrapper-rules.md`, and run in CI.
4
+ */
5
+ import { createWrapperRule } from '@eslint-sh/eslint-plugin';
6
+
7
+ const requireAudit = createWrapperRule({
8
+ commands: ['kubectl', 'terraform'],
9
+ wrapper: { names: ['audit-run'], argFlags: ['--ticket'] },
10
+ subcommands: {
11
+ terraform: { names: ['apply', 'destroy'], noArgFlags: ['-no-color'] },
12
+ },
13
+ });
14
+
15
+ export default { rules: { 'require-audit': requireAudit } };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A custom declaration convention: each dependency is declared on its own
3
+ * `# @dep <name>` line. From `../declaration-conventions.md`, and run in CI.
4
+ */
5
+ const depLines = {
6
+ declaredCommands(sourceCode) {
7
+ const declared = [];
8
+ for (const match of sourceCode.text.matchAll(/^# @dep (\S+)[ \t]*$/gmu)) {
9
+ const start = match.index + '# @dep '.length;
10
+ declared.push({ kind: 'script', name: match[1], range: [start, start + match[1].length] });
11
+ }
12
+ return declared;
13
+ },
14
+ removeDeclaration(sourceCode, declared, fixer) {
15
+ const { text } = sourceCode;
16
+ const lineStart = text.lastIndexOf('\n', declared.range[0]) + 1;
17
+ const newline = text.indexOf('\n', declared.range[1]);
18
+ return fixer.removeRange([lineStart, newline === -1 ? text.length : newline + 1]);
19
+ },
20
+ };
21
+
22
+ export default depLines;
@@ -1,7 +1,7 @@
1
1
  // eslint.config.mjs
2
2
  //
3
3
  // The custom-rule tutorial's exact flat-config wiring
4
- // (`../custom-rule-tutorial.md`, step 3). `test/custom-rule-tutorial.test.ts`
4
+ // (`../custom-rule-tutorial.md`, step 3). This package's test suite
5
5
  // imports this file's default export directly and runs it through a real
6
6
  // `eslint` `Linter.verify()` — not a reconstruction of this shape — so the
7
7
  // documented wiring is genuinely tested.
@@ -10,7 +10,8 @@ import noRmRf from './no-rm-rf-rule.mjs';
10
10
 
11
11
  export default [
12
12
  {
13
- files: ['**/*.sh', '**/*.bash'],
13
+ // Extensionless scripts need `bin/?*`: ESLint never lints a file matched only by `bin/*` or `bin/**`.
14
+ files: ['**/*.sh', '**/*.bash', 'bin/?*'],
14
15
  plugins: {
15
16
  sh,
16
17
  'my-rules': { rules: { 'no-rm-rf': noRmRf } },
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The "report on an operator" custom rule from the custom-rule tutorial
3
+ * (`../custom-rule-tutorial.md`, step 6). Plain, directly-runnable
4
+ * JavaScript; this package's test suite imports this exact file and
5
+ * asserts the tutorial's code fence matches it byte-for-byte.
6
+ *
7
+ * It reports `A && B || C`, which reads like if-then-else but is not: `C`
8
+ * also runs when `A` succeeds and `B` fails. The report sits on the `&&`
9
+ * itself. The lint view has no operator positions, so the rule reads the
10
+ * original sh-ast record with `getSyntax` and converts its byte position
11
+ * `OpPos` to ESLint coordinates with `getIndexFromSyntaxPos` and
12
+ * `getLocFromSyntaxPos`.
13
+ *
14
+ * @see ../custom-rule-tutorial.md — the tutorial this rule backs
15
+ */
16
+ import { defineShellRule } from '@eslint-sh/eslint-plugin';
17
+
18
+ const noAndOrAsIf = defineShellRule({
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description: 'disallow `A && B || C` used as if-then-else',
23
+ },
24
+ schema: [],
25
+ messages: {
26
+ andOr: '`A && B || C` is not if-then-else: C also runs when B fails. Use `if A; then B; else C; fi`.',
27
+ },
28
+ },
29
+ create(context) {
30
+ const { sourceCode } = context;
31
+
32
+ /** The two-character operator a `BinaryCmd` record's `OpPos` points at. */
33
+ const operatorOf = (binaryCmd) => {
34
+ const start = sourceCode.getIndexFromSyntaxPos(binaryCmd.OpPos);
35
+ return sourceCode.text.slice(start, start + 2);
36
+ };
37
+
38
+ return {
39
+ BinaryCmd(node) {
40
+ // `a && b || c` parses as `(a && b) || c`: the `||` command's left
41
+ // statement holds the `&&` command.
42
+ const or = sourceCode.getSyntax(node);
43
+ const and = or.X?.Cmd;
44
+ if (operatorOf(or) !== '||' || and?.Type !== 'BinaryCmd' || operatorOf(and) !== '&&') {
45
+ return;
46
+ }
47
+
48
+ const start = sourceCode.getIndexFromSyntaxPos(and.OpPos);
49
+ context.report({
50
+ loc: {
51
+ start: sourceCode.getLocFromSyntaxPos(and.OpPos),
52
+ end: sourceCode.getLocFromIndex(start + 2),
53
+ },
54
+ messageId: 'andOr',
55
+ });
56
+ },
57
+ };
58
+ },
59
+ });
60
+
61
+ export default noAndOrAsIf;