@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.
- package/README.md +223 -17
- package/dist/command-harvest/builtins/bash.d.ts +36 -0
- package/dist/command-harvest/builtins/bash.js +98 -0
- package/dist/command-harvest/builtins/bats.d.ts +45 -0
- package/dist/command-harvest/builtins/bats.js +33 -0
- package/dist/command-harvest/builtins/index.d.ts +18 -0
- package/dist/command-harvest/builtins/index.js +39 -0
- package/dist/command-harvest/builtins/posix.d.ts +51 -0
- package/dist/command-harvest/builtins/posix.js +93 -0
- package/dist/command-harvest/harvest-commands.d.ts +66 -0
- package/dist/command-harvest/harvest-commands.js +97 -0
- package/dist/command-harvest/wrapper-table.d.ts +24 -0
- package/dist/command-harvest/wrapper-table.js +49 -0
- package/dist/configs/agent-authored.d.ts +41 -0
- package/dist/configs/agent-authored.js +33 -0
- package/dist/configs/bash-3-2.d.ts +28 -0
- package/dist/configs/bash-3-2.js +50 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +52 -0
- package/dist/configs/recommended.d.ts +52 -0
- package/dist/configs/recommended.js +34 -0
- package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
- package/dist/declaration-conventions/comment-pragma.js +153 -0
- package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
- package/dist/declaration-conventions/declaration-convention.js +1 -0
- package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
- package/dist/declaration-conventions/read-declared-commands.js +128 -0
- package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
- package/dist/eslint-plugin-syntax.d.ts +27 -0
- package/dist/eslint-plugin.d.ts +1211 -52
- package/dist/index.d.ts +56 -8
- package/dist/index.js +53 -6
- package/dist/language/command-resolution.d.ts +124 -0
- package/dist/language/command-resolution.js +120 -0
- package/dist/language/end-of-input-marker.d.ts +29 -0
- package/dist/language/end-of-input-marker.js +88 -0
- package/dist/language/line-breaks.d.ts +18 -0
- package/dist/language/line-breaks.js +22 -0
- package/dist/language/lint-view-types.d.ts +104 -0
- package/dist/language/lint-view-types.js +1 -0
- package/dist/language/non-shell-file-guard.d.ts +35 -0
- package/dist/language/non-shell-file-guard.js +59 -0
- package/dist/language/selector-classes.d.ts +0 -1
- package/dist/language/selector-classes.js +0 -1
- package/dist/language/shell-language-options.d.ts +7 -1
- package/dist/language/shell-language-options.js +4 -4
- package/dist/language/shell-language.d.ts +10 -2
- package/dist/language/shell-language.js +59 -8
- package/dist/language/shell-source-code.d.ts +342 -5
- package/dist/language/shell-source-code.js +559 -30
- package/dist/language/source-coordinates.d.ts +13 -2
- package/dist/language/source-coordinates.js +68 -14
- package/dist/language/syntax-view.d.ts +60 -39
- package/dist/language/syntax-view.js +200 -45
- package/dist/language/word-resolution.d.ts +4 -3
- package/dist/language/word-resolution.js +4 -3
- package/dist/rules/compat/create-compat-rule.d.ts +59 -0
- package/dist/rules/compat/create-compat-rule.js +65 -0
- package/dist/rules/compat/declaration-options.d.ts +19 -0
- package/dist/rules/compat/declaration-options.js +110 -0
- package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
- package/dist/rules/compat/no-append-all-redirect.js +73 -0
- package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
- package/dist/rules/compat/no-associative-arrays.js +39 -0
- package/dist/rules/compat/no-case-attributes.d.ts +12 -0
- package/dist/rules/compat/no-case-attributes.js +63 -0
- package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
- package/dist/rules/compat/no-case-fallthrough.js +67 -0
- package/dist/rules/compat/no-case-modification.d.ts +35 -0
- package/dist/rules/compat/no-case-modification.js +86 -0
- package/dist/rules/compat/no-coproc.d.ts +12 -0
- package/dist/rules/compat/no-coproc.js +47 -0
- package/dist/rules/compat/no-declare-g.d.ts +12 -0
- package/dist/rules/compat/no-declare-g.js +59 -0
- package/dist/rules/compat/no-double-bracket.d.ts +12 -0
- package/dist/rules/compat/no-double-bracket.js +52 -0
- package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
- package/dist/rules/compat/no-epoch-variables.js +57 -0
- package/dist/rules/compat/no-fd-variable.d.ts +12 -0
- package/dist/rules/compat/no-fd-variable.js +48 -0
- package/dist/rules/compat/no-globstar.d.ts +12 -0
- package/dist/rules/compat/no-globstar.js +39 -0
- package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
- package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
- package/dist/rules/compat/no-let.d.ts +12 -0
- package/dist/rules/compat/no-let.js +62 -0
- package/dist/rules/compat/no-mapfile.d.ts +13 -0
- package/dist/rules/compat/no-mapfile.js +122 -0
- package/dist/rules/compat/no-nameref.d.ts +12 -0
- package/dist/rules/compat/no-nameref.js +44 -0
- package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
- package/dist/rules/compat/no-negative-array-index.js +123 -0
- package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
- package/dist/rules/compat/no-nounset-empty-array.js +158 -0
- package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
- package/dist/rules/compat/no-parameter-transformation.js +123 -0
- package/dist/rules/compat/no-pipe-all.d.ts +13 -0
- package/dist/rules/compat/no-pipe-all.js +68 -0
- package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
- package/dist/rules/compat/no-printf-time-format.js +99 -0
- package/dist/rules/compat/no-source.d.ts +13 -0
- package/dist/rules/compat/no-source.js +103 -0
- package/dist/rules/compat/no-test-v.d.ts +12 -0
- package/dist/rules/compat/no-test-v.js +280 -0
- package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
- package/dist/rules/compat/no-unicode-escape.js +120 -0
- package/dist/rules/compat/no-wait-n.d.ts +12 -0
- package/dist/rules/compat/no-wait-n.js +73 -0
- package/dist/rules/compat/operator-range.d.ts +10 -0
- package/dist/rules/compat/operator-range.js +15 -0
- package/dist/rules/compat/posix-absent-command.d.ts +12 -0
- package/dist/rules/compat/posix-absent-command.js +134 -0
- package/dist/rules/compat/shopt-options.d.ts +9 -0
- package/dist/rules/compat/shopt-options.js +63 -0
- package/dist/rules/compat/version-gates.d.ts +76 -0
- package/dist/rules/compat/version-gates.js +197 -0
- package/dist/rules/create-rule.d.ts +91 -0
- package/dist/rules/create-rule.js +51 -0
- package/dist/rules/create-wrapper-rule.d.ts +166 -0
- package/dist/rules/create-wrapper-rule.js +407 -0
- package/dist/rules/define-shell-rule.d.ts +184 -0
- package/dist/rules/define-shell-rule.js +56 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.js +88 -0
- package/dist/rules/max-script-complexity.d.ts +28 -0
- package/dist/rules/max-script-complexity.js +130 -0
- package/dist/rules/no-dynamic-invocation.d.ts +35 -0
- package/dist/rules/no-dynamic-invocation.js +117 -0
- package/dist/rules/no-eval.d.ts +13 -0
- package/dist/rules/no-eval.js +89 -0
- package/dist/rules/no-masked-exit-status.d.ts +21 -0
- package/dist/rules/no-masked-exit-status.js +667 -0
- package/dist/rules/no-secret-env-reads.d.ts +24 -0
- package/dist/rules/no-secret-env-reads.js +133 -0
- package/dist/rules/no-secret-flags.d.ts +32 -0
- package/dist/rules/no-secret-flags.js +162 -0
- package/dist/rules/no-unchecked-cd.d.ts +44 -0
- package/dist/rules/no-unchecked-cd.js +250 -0
- package/dist/rules/no-undeclared-commands.d.ts +20 -0
- package/dist/rules/no-undeclared-commands.js +115 -0
- package/dist/rules/no-unused-declarations.d.ts +23 -0
- package/dist/rules/no-unused-declarations.js +259 -0
- package/dist/rules/prefer-local.d.ts +16 -0
- package/dist/rules/prefer-local.js +249 -0
- package/dist/rules/quote-expansions.d.ts +13 -0
- package/dist/rules/quote-expansions.js +229 -0
- package/dist/rules/require-network-wrapper.d.ts +40 -0
- package/dist/rules/require-network-wrapper.js +244 -0
- package/dist/rules/require-shebang.d.ts +29 -0
- package/dist/rules/require-shebang.js +101 -0
- package/dist/rules/require-strict-mode.d.ts +52 -0
- package/dist/rules/require-strict-mode.js +177 -0
- package/dist/rules/require-tty-guard.d.ts +13 -0
- package/dist/rules/require-tty-guard.js +289 -0
- package/dist/rules/resolve-declaration-convention.d.ts +96 -0
- package/dist/rules/resolve-declaration-convention.js +109 -0
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +116 -0
- package/dist/rules/set-options.d.ts +109 -0
- package/dist/rules/set-options.js +201 -0
- package/dist/rules/shebang-interpreter.d.ts +18 -0
- package/dist/rules/shebang-interpreter.js +81 -0
- package/dist/rules/shebang-matches-dialect.d.ts +12 -0
- package/dist/rules/shebang-matches-dialect.js +69 -0
- package/dist/rules/transparent-wrappers.d.ts +52 -0
- package/dist/rules/transparent-wrappers.js +99 -0
- package/dist/rules/user-pattern.d.ts +63 -0
- package/dist/rules/user-pattern.js +184 -0
- package/dist/syntax/analyze.d.ts +19 -0
- package/dist/syntax/analyze.js +21 -0
- package/dist/syntax/index.d.ts +23 -0
- package/dist/syntax/index.js +25 -0
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/docs/compat.md +347 -0
- package/docs/custom-rule-tutorial.md +780 -34
- package/docs/declaration-conventions.md +478 -0
- package/docs/examples/audit-wrapper-pack.mjs +15 -0
- package/docs/examples/dep-line-convention.mjs +22 -0
- package/docs/examples/eslint.config.mjs +3 -2
- package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
- package/docs/examples/no-backticks-rule-tester.mjs +106 -0
- package/docs/examples/no-backticks-rule.mjs +60 -0
- package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
- package/docs/examples/no-function-keyword-rule.mjs +59 -0
- package/docs/examples/no-listed-commands-rule.ts +63 -0
- package/docs/examples/no-rm-rf-rule.mjs +26 -15
- package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
- package/docs/examples/sidecar-convention.mjs +38 -0
- package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
- package/docs/examples/sorted-declarations-rule.mjs +52 -0
- package/docs/reading-nodes.md +159 -0
- package/docs/rules/README.md +78 -0
- package/docs/rules/_template.md +83 -0
- package/docs/rules/compat/no-append-all-redirect.md +87 -0
- package/docs/rules/compat/no-associative-arrays.md +95 -0
- package/docs/rules/compat/no-case-attributes.md +82 -0
- package/docs/rules/compat/no-case-fallthrough.md +90 -0
- package/docs/rules/compat/no-case-modification.md +74 -0
- package/docs/rules/compat/no-coproc.md +76 -0
- package/docs/rules/compat/no-declare-g.md +80 -0
- package/docs/rules/compat/no-double-bracket.md +72 -0
- package/docs/rules/compat/no-epoch-variables.md +79 -0
- package/docs/rules/compat/no-fd-variable.md +78 -0
- package/docs/rules/compat/no-globstar.md +74 -0
- package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
- package/docs/rules/compat/no-let.md +78 -0
- package/docs/rules/compat/no-mapfile.md +118 -0
- package/docs/rules/compat/no-nameref.md +81 -0
- package/docs/rules/compat/no-negative-array-index.md +87 -0
- package/docs/rules/compat/no-nounset-empty-array.md +103 -0
- package/docs/rules/compat/no-parameter-transformation.md +94 -0
- package/docs/rules/compat/no-pipe-all.md +85 -0
- package/docs/rules/compat/no-printf-time-format.md +75 -0
- package/docs/rules/compat/no-source.md +98 -0
- package/docs/rules/compat/no-test-v.md +82 -0
- package/docs/rules/compat/no-unicode-escape.md +91 -0
- package/docs/rules/compat/no-wait-n.md +85 -0
- package/docs/rules/max-script-complexity.md +109 -0
- package/docs/rules/no-dynamic-invocation.md +139 -0
- package/docs/rules/no-eval.md +106 -0
- package/docs/rules/no-masked-exit-status.md +239 -0
- package/docs/rules/no-secret-env-reads.md +173 -0
- package/docs/rules/no-secret-flags.md +169 -0
- package/docs/rules/no-unchecked-cd.md +146 -0
- package/docs/rules/no-undeclared-commands.md +141 -0
- package/docs/rules/no-unused-declarations.md +216 -0
- package/docs/rules/prefer-local.md +221 -0
- package/docs/rules/quote-expansions.md +157 -0
- package/docs/rules/require-network-wrapper.md +171 -0
- package/docs/rules/require-shebang.md +157 -0
- package/docs/rules/require-strict-mode.md +253 -0
- package/docs/rules/require-tty-guard.md +128 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +86 -0
- package/docs/wrapper-rules.md +206 -0
- package/package.json +26 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/language/selector-classes.d.ts.map +0 -1
- package/dist/language/selector-classes.js.map +0 -1
- package/dist/language/shell-language-options.d.ts.map +0 -1
- package/dist/language/shell-language-options.js.map +0 -1
- package/dist/language/shell-language.d.ts.map +0 -1
- package/dist/language/shell-language.js.map +0 -1
- package/dist/language/shell-source-code.d.ts.map +0 -1
- package/dist/language/shell-source-code.js.map +0 -1
- package/dist/language/source-coordinates.d.ts.map +0 -1
- package/dist/language/source-coordinates.js.map +0 -1
- package/dist/language/syntax-view.d.ts.map +0 -1
- package/dist/language/syntax-view.js.map +0 -1
- package/dist/language/word-resolution.d.ts.map +0 -1
- package/dist/language/word-resolution.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -5,25 +5,29 @@ one of those selectors to a complete, runnable ESLint rule: a `{ meta, create(co
|
|
|
5
5
|
a `context.report` call with a message and location, and the exact flat-config (`eslint.config.mjs`)
|
|
6
6
|
wiring — `plugins`, `rules`, `language` — needed to run it.
|
|
7
7
|
|
|
8
|
-
Every
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
[`examples
|
|
12
|
-
`
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
8
|
+
Every rule on this page is built with `defineShellRule`, the plugin's rule-authoring helper (step
|
|
9
|
+
2 explains what it does). Every code block that names a file is the literal, shipped file it
|
|
10
|
+
names, not a paraphrase, and every one is executed by this package's own test suite, which imports
|
|
11
|
+
the files in [`examples/`](./examples/) directly and runs them through a real `eslint`
|
|
12
|
+
`Linter.verify()`, and type-checks the TypeScript example in step 10 against the published
|
|
13
|
+
declarations. A rule fires on a fixture script and does not fire on a clean one, so this tutorial
|
|
14
|
+
cannot silently drift from working code. To write the same kind of test for your own rule, see
|
|
15
|
+
[step 11](#11-test-your-rule), whose example [`no-backticks-rule-tester.mjs`](./examples/no-backticks-rule-tester.mjs)
|
|
16
|
+
you can run yourself. All example files ship with this package
|
|
17
|
+
(`docs/examples/`), so you can copy them into your own project as-is. The JavaScript examples use
|
|
18
|
+
the `.mjs` extension deliberately: they are ES modules (`import`/`export`), and a default
|
|
19
|
+
`npm init -y` project is CommonJS, so a plain `.js` extension would make Node — and ESLint's own
|
|
20
|
+
config loader — try to parse them as CommonJS and fail with `SyntaxError: Cannot use import
|
|
21
|
+
statement outside a module`. `.mjs` marks each file as an ES module regardless of the surrounding
|
|
22
|
+
project's `"type"` field.
|
|
20
23
|
|
|
21
24
|
## 1. Pick a selector from the cookbook
|
|
22
25
|
|
|
23
26
|
The cookbook's `:command` [launch class](./selectors.md#the-six-launch-classes) matches every
|
|
24
27
|
`CallExpr` node — every shell command invocation. That's the selector this tutorial's rule listens
|
|
25
|
-
on; `create()` then narrows further (is the command `rm`? does it pass
|
|
26
|
-
|
|
28
|
+
on; `create()` then narrows further (is the command `rm`? does it pass recursive and force short
|
|
29
|
+
flags, such as `-rf`, `-fr`, or `-r -f`?) the same way a hand-written rule narrows any other
|
|
30
|
+
selector-first ESLint rule.
|
|
27
31
|
|
|
28
32
|
## 2. Write the rule module
|
|
29
33
|
|
|
@@ -34,10 +38,13 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
|
|
|
34
38
|
* The complete "flag `rm -rf`" custom rule from the custom-rule tutorial
|
|
35
39
|
* (`../custom-rule-tutorial.md`). Plain, directly-runnable JavaScript (no
|
|
36
40
|
* compile step) — this file *is* the tutorial's rule-module code fence,
|
|
37
|
-
* copy-paste-verbatim;
|
|
41
|
+
* copy-paste-verbatim; this package's test suite imports this
|
|
38
42
|
* exact file (not a reconstruction) and asserts the markdown fence matches
|
|
39
43
|
* it byte-for-byte, so the two can't silently drift.
|
|
40
44
|
*
|
|
45
|
+
* Built with `defineShellRule`, which declares `meta.languages: ['sh/sh']`
|
|
46
|
+
* for it, so ESLint refuses to run the rule on a file of any other language.
|
|
47
|
+
*
|
|
41
48
|
* Built on the `:command` launch class from the selector cookbook
|
|
42
49
|
* (`../selectors.md`) — `:command` matches every `CallExpr`, so `create()`
|
|
43
50
|
* narrows further by inspecting the command word and its flag arguments,
|
|
@@ -48,12 +55,16 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
|
|
|
48
55
|
*
|
|
49
56
|
* Flag words are resolved with `@eslint-sh/eslint-plugin`'s view-aware
|
|
50
57
|
* `resolveWord` (backed by `sh-ast/analyze`) rather than a
|
|
51
|
-
* hand-rolled scan: a word is only
|
|
52
|
-
*
|
|
53
|
-
* concatenations)
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
58
|
+
* hand-rolled scan: a word is only treated as a short-option cluster
|
|
59
|
+
* when it is *statically* that string (bare or
|
|
60
|
+
* quoted literal concatenations). Recursive (`r`/`R`) and force (`f`)
|
|
61
|
+
* flags accumulate across words before `--`; uppercase `F` is not force.
|
|
62
|
+
* Options are matched anywhere before `--`, GNU-style, even after operands.
|
|
63
|
+
* This teaching example does not model long options or option precedence.
|
|
64
|
+
* A word containing a `ParamExp`/`CmdSubst`/`ArithmExp`/
|
|
65
|
+
* `ProcSubst` resolves to `{ static: false, reason }` (`'expansion'`,
|
|
66
|
+
* `'tilde'`, `'glob'`, …) rather than being silently erased into a static
|
|
67
|
+
* match — see `resolveWord`'s API docs. Importing
|
|
57
68
|
* it from the plugin package (rather than `sh-ast/analyze` directly) means
|
|
58
69
|
* a project that installs only `@eslint-sh/eslint-plugin` has everything
|
|
59
70
|
* this rule needs, with no separate `sh-ast` install or npm-hoisting
|
|
@@ -62,13 +73,13 @@ flag?) the same way a hand-written rule narrows any other selector-first ESLint
|
|
|
62
73
|
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
63
74
|
* @see ../selectors.md — the `:command` launch class
|
|
64
75
|
*/
|
|
65
|
-
import { resolveWord } from '@eslint-sh/eslint-plugin';
|
|
76
|
+
import { defineShellRule, resolveWord } from '@eslint-sh/eslint-plugin';
|
|
66
77
|
|
|
67
|
-
const noRmRf = {
|
|
78
|
+
const noRmRf = defineShellRule({
|
|
68
79
|
meta: {
|
|
69
80
|
type: 'problem',
|
|
70
81
|
docs: {
|
|
71
|
-
description: 'disallow
|
|
82
|
+
description: 'disallow recursive-force `rm` invocations',
|
|
72
83
|
},
|
|
73
84
|
schema: [],
|
|
74
85
|
messages: {
|
|
@@ -94,11 +105,15 @@ const noRmRf = {
|
|
|
94
105
|
const endOfOptions = words.indexOf('--');
|
|
95
106
|
const flagCandidates = words.slice(1, endOfOptions === -1 ? undefined : endOfOptions);
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
// Only static short-option clusters contribute flags; operands,
|
|
109
|
+
// long options, and unresolved words supply no evidence. Recursive
|
|
110
|
+
// and force may occur in the same word or in separate words.
|
|
111
|
+
const shortOptions = flagCandidates.filter(
|
|
112
|
+
(word) => word !== null && /^-[a-zA-Z]+$/.test(word),
|
|
101
113
|
);
|
|
114
|
+
const hasRecursive = shortOptions.some((word) => /[rR]/.test(word));
|
|
115
|
+
const hasForce = shortOptions.some((word) => word.includes('f'));
|
|
116
|
+
const isRecursiveForce = hasRecursive && hasForce;
|
|
102
117
|
|
|
103
118
|
if (isRecursiveForce) {
|
|
104
119
|
context.report({ node, loc: node.loc, messageId: 'rmRf' });
|
|
@@ -106,13 +121,25 @@ const noRmRf = {
|
|
|
106
121
|
},
|
|
107
122
|
};
|
|
108
123
|
},
|
|
109
|
-
};
|
|
124
|
+
});
|
|
110
125
|
|
|
111
126
|
export default noRmRf;
|
|
112
127
|
```
|
|
113
128
|
|
|
129
|
+
`defineShellRule` takes the `{ meta, create(context) }` object you would write for any ESLint rule
|
|
130
|
+
and returns it as an ordinary ESLint rule, with one addition: `meta.languages: ['sh/sh']`. That
|
|
131
|
+
declaration is what makes ESLint refuse to run the rule on a file of another language. Without it,
|
|
132
|
+
a rule enabled for a file that ESLint treats as JavaScript (typically because its config entry is
|
|
133
|
+
missing `language: 'sh/sh'`) runs without error and never matches, so nothing tells you it was
|
|
134
|
+
misconfigured. With it, ESLint stops with a configuration error naming the rule:
|
|
135
|
+
`The following rules do not support the language "js/js"`. The helper always sets the field, so a
|
|
136
|
+
rule built with it can't forget it, and in TypeScript writing `meta.languages` yourself is a
|
|
137
|
+
compile error. Everything else in `meta`, including your own `docs.url`, is kept as you wrote it.
|
|
138
|
+
|
|
114
139
|
A few things worth calling out:
|
|
115
140
|
|
|
141
|
+
- `defineShellRule` and `resolveWord` both come from `@eslint-sh/eslint-plugin` (or the umbrella
|
|
142
|
+
`eslint-sh`). A rule needs no other package: not `sh-ast`, and not `@eslint/core`.
|
|
116
143
|
- The listener key, `':command'`, is a selector string — one of the [six launch
|
|
117
144
|
classes](./selectors.md#the-six-launch-classes) — not a bare node-type name. ESLint's
|
|
118
145
|
`esquery`-based rule-listener matching (via `ShellLanguage.matchesSelectorClass`) treats it
|
|
@@ -123,6 +150,12 @@ A few things worth calling out:
|
|
|
123
150
|
quoted literal (`rm`, `'r'm`, `"-rf"` all resolve), and `{ static: false, reason }` the moment any
|
|
124
151
|
part of the word depends on a `ParamExp`/`CmdSubst`/`ArithmExp`/`ProcSubst` — never inventing a
|
|
125
152
|
value for a dynamic word.
|
|
153
|
+
- Short flags accumulate across static words before the first `--`: `rm -rf build`,
|
|
154
|
+
`rm -r -f build`, and `rm -R -f build` all report, while `rm -r build`, `rm -f build`,
|
|
155
|
+
and `rm -r -- -f` do not. Recursive accepts `r` or `R`; force is lowercase `f`, so `-rF`
|
|
156
|
+
does not supply both flags. Options are matched anywhere before `--`, GNU-style, even after
|
|
157
|
+
operands (for example, `rm build -rf` reports). This example focuses on short options; it does not
|
|
158
|
+
model long options or option precedence (such as a later `-i` overriding `-f`).
|
|
126
159
|
- `context.report({ node, loc: node.loc, messageId: 'rmRf' })` reports with both a message (via
|
|
127
160
|
`meta.messages.rmRf`) and an explicit location (`node.loc`, `ShellSourceCode`'s
|
|
128
161
|
`{ start: { line, column }, end: { line, column } }` shape) — the two pieces every ESLint lint
|
|
@@ -136,7 +169,7 @@ A few things worth calling out:
|
|
|
136
169
|
// eslint.config.mjs
|
|
137
170
|
//
|
|
138
171
|
// The custom-rule tutorial's exact flat-config wiring
|
|
139
|
-
// (`../custom-rule-tutorial.md`, step 3).
|
|
172
|
+
// (`../custom-rule-tutorial.md`, step 3). This package's test suite
|
|
140
173
|
// imports this file's default export directly and runs it through a real
|
|
141
174
|
// `eslint` `Linter.verify()` — not a reconstruction of this shape — so the
|
|
142
175
|
// documented wiring is genuinely tested.
|
|
@@ -145,7 +178,8 @@ import noRmRf from './no-rm-rf-rule.mjs';
|
|
|
145
178
|
|
|
146
179
|
export default [
|
|
147
180
|
{
|
|
148
|
-
|
|
181
|
+
// Extensionless scripts need `bin/?*`: ESLint never lints a file matched only by `bin/*` or `bin/**`.
|
|
182
|
+
files: ['**/*.sh', '**/*.bash', 'bin/?*'],
|
|
149
183
|
plugins: {
|
|
150
184
|
sh,
|
|
151
185
|
'my-rules': { rules: { 'no-rm-rf': noRmRf } },
|
|
@@ -158,6 +192,9 @@ export default [
|
|
|
158
192
|
];
|
|
159
193
|
```
|
|
160
194
|
|
|
195
|
+
- `files` covers `.sh`/`.bash` files and the extensionless scripts directly in `bin/`. Use
|
|
196
|
+
`bin/?*`, not `bin/*` or `bin/**`: ESLint skips a file that only a pattern ending in `*` or
|
|
197
|
+
`/**` matches. The `eslint-sh` README's Troubleshooting section lists other patterns that work.
|
|
161
198
|
- `plugins.sh` supplies `languages.sh` (the `ShellLanguage`/`ShellSourceCode` core) — required for
|
|
162
199
|
`language: 'sh/sh'` to resolve.
|
|
163
200
|
- `plugins['my-rules']` is a second, minimal plugin object whose only job is to register the rule
|
|
@@ -174,7 +211,716 @@ rm -rf "$BUILD_DIR"
|
|
|
174
211
|
```
|
|
175
212
|
|
|
176
213
|
the rule reports once, at the `CallExpr`'s location, with the message text from
|
|
177
|
-
`meta.messages.rmRf`. Given a clean script with no `rm
|
|
178
|
-
no messages at all — both cases are asserted in
|
|
179
|
-
`eslint` `Linter.verify()` run (not a hand-built approximation of one), using the real
|
|
214
|
+
`meta.messages.rmRf`. Given a clean script with no `rm` invocation combining recursive and force
|
|
215
|
+
short flags, the rule produces no messages at all — both cases are asserted in
|
|
216
|
+
this package's test suite against a real `eslint` `Linter.verify()` run (not a hand-built approximation of one), using the real
|
|
180
217
|
`docs/examples/eslint.config.mjs` shown in step 3.
|
|
218
|
+
|
|
219
|
+
## 5. Every command in the file
|
|
220
|
+
|
|
221
|
+
The `:command` listener above sees one `CallExpr` at a time. Some rules need every command in the
|
|
222
|
+
file together with how each one is reached: inside which function, inside `$(…)`, in which
|
|
223
|
+
pipeline stage. `context.sourceCode.getCommandSites()` returns that list, one entry per command in
|
|
224
|
+
source order, from `enumerateCommands` (in `@eslint-sh/eslint-plugin/syntax/analyze`). Each
|
|
225
|
+
entry has:
|
|
226
|
+
|
|
227
|
+
- `node`: the `CallExpr` node, the same object a `CallExpr` listener receives, so you can report
|
|
228
|
+
on it.
|
|
229
|
+
- `context`: the frames that lead to the command, outermost first. `date` in
|
|
230
|
+
`f() { echo "$(date)"; }` has `[{ kind: 'function', name: 'f' }, { kind: 'cmdSubst' }]`.
|
|
231
|
+
- `argv0` and `argv`: the command's words, resolved like `resolveWord` resolves them.
|
|
232
|
+
- `resolveInvokedCommand()`: the command that actually runs after transparent wrappers such as
|
|
233
|
+
`sudo` or `env`. Its `chainIndices` say where each word of that chain sits in the site's `argv`,
|
|
234
|
+
so the command's own arguments are `site.argv.slice(chainIndices.at(-1) + 1)`. `site.argv` is
|
|
235
|
+
the whole command line, wrappers included, so slicing from `0` or `1` would hand you
|
|
236
|
+
`sudo`'s arguments for `sudo chmod 777 x`.
|
|
237
|
+
|
|
238
|
+
An assignment-only statement such as `FOO=1` runs no command and has no entry.
|
|
239
|
+
|
|
240
|
+
`docs/examples/no-curl-in-functions-rule.mjs` reports `curl` anywhere inside a shell function:
|
|
241
|
+
|
|
242
|
+
```mjs
|
|
243
|
+
/**
|
|
244
|
+
* The "every command in the file" custom rule from the custom-rule tutorial
|
|
245
|
+
* (`../custom-rule-tutorial.md`, step 5). Plain, directly-runnable
|
|
246
|
+
* JavaScript; this package's test suite imports this exact file and
|
|
247
|
+
* asserts the tutorial's code fence matches it byte-for-byte.
|
|
248
|
+
*
|
|
249
|
+
* It reports `curl` run anywhere inside a shell function, including through a
|
|
250
|
+
* wrapper (`sudo curl …`) or inside a command substitution in that function.
|
|
251
|
+
* Instead of tracking "am I inside a function?" while visiting nodes, it reads
|
|
252
|
+
* every command once from `context.sourceCode.getCommandSites()`, whose
|
|
253
|
+
* `context` frames (outermost first) already say how each command is reached.
|
|
254
|
+
*
|
|
255
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
256
|
+
*/
|
|
257
|
+
import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
258
|
+
|
|
259
|
+
const noCurlInFunctions = defineShellRule({
|
|
260
|
+
meta: {
|
|
261
|
+
type: 'suggestion',
|
|
262
|
+
docs: {
|
|
263
|
+
description: 'disallow `curl` inside shell functions',
|
|
264
|
+
},
|
|
265
|
+
schema: [],
|
|
266
|
+
messages: {
|
|
267
|
+
curlInFunction: 'Function `{{name}}` runs `curl`; fetch data before calling it.',
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
create(context) {
|
|
271
|
+
return {
|
|
272
|
+
File() {
|
|
273
|
+
for (const site of context.sourceCode.getCommandSites()) {
|
|
274
|
+
const { effective } = site.resolveInvokedCommand();
|
|
275
|
+
if (!effective.static || effective.text !== 'curl') {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Frames run outermost first, so the last `function` frame is the
|
|
280
|
+
// function that directly contains this command.
|
|
281
|
+
const fn = site.context.findLast((frame) => frame.kind === 'function');
|
|
282
|
+
if (fn) {
|
|
283
|
+
context.report({
|
|
284
|
+
node: site.node,
|
|
285
|
+
messageId: 'curlInFunction',
|
|
286
|
+
data: { name: fn.name },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
export default noCurlInFunctions;
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Register it the same way as the rule in step 3. For
|
|
299
|
+
|
|
300
|
+
```sh
|
|
301
|
+
fetch() {
|
|
302
|
+
sudo curl -fsSL "$1"
|
|
303
|
+
}
|
|
304
|
+
curl -fsSL https://example.com
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
it reports once, on line 2: the `curl` in `fetch` runs through `sudo`, and the top-level `curl` is
|
|
308
|
+
outside any function. This package's test suite runs this case through a real `eslint`
|
|
309
|
+
`Linter`.
|
|
310
|
+
|
|
311
|
+
## 6. Report on an operator
|
|
312
|
+
|
|
313
|
+
Lint view nodes carry a `range` and `loc` for the whole node, but not the positions of the tokens
|
|
314
|
+
inside it, such as the `&&` of a `BinaryCmd`. Those live on the original sh-ast record:
|
|
315
|
+
|
|
316
|
+
- `context.sourceCode.getSyntax(node)` returns the sh-ast record a view node was parsed from, with
|
|
317
|
+
upstream field names (`X`, `Y`, `OpPos`, …). Treat it as read-only: every rule and helper linting
|
|
318
|
+
the file shares it.
|
|
319
|
+
- `context.sourceCode.getIndexFromSyntaxPos(pos)` and `getLocFromSyntaxPos(pos)` convert one of that
|
|
320
|
+
record's positions into the UTF-16 index and one-based location ESLint uses. sh-ast positions count
|
|
321
|
+
UTF-8 bytes, so use these instead of the raw `Offset` or `Col` whenever text before the position
|
|
322
|
+
might not be ASCII.
|
|
323
|
+
- `context.sourceCode.getViewNode(record)` goes the other way, from a record back to the view node
|
|
324
|
+
you can pass to `context.report({ node })`.
|
|
325
|
+
|
|
326
|
+
`docs/examples/no-and-or-as-if-rule.mjs` reports `A && B || C`, which reads like if-then-else but
|
|
327
|
+
also runs `C` when `B` fails, and puts the report on the `&&`:
|
|
328
|
+
|
|
329
|
+
```mjs
|
|
330
|
+
/**
|
|
331
|
+
* The "report on an operator" custom rule from the custom-rule tutorial
|
|
332
|
+
* (`../custom-rule-tutorial.md`, step 6). Plain, directly-runnable
|
|
333
|
+
* JavaScript; this package's test suite imports this exact file and
|
|
334
|
+
* asserts the tutorial's code fence matches it byte-for-byte.
|
|
335
|
+
*
|
|
336
|
+
* It reports `A && B || C`, which reads like if-then-else but is not: `C`
|
|
337
|
+
* also runs when `A` succeeds and `B` fails. The report sits on the `&&`
|
|
338
|
+
* itself. The lint view has no operator positions, so the rule reads the
|
|
339
|
+
* original sh-ast record with `getSyntax` and converts its byte position
|
|
340
|
+
* `OpPos` to ESLint coordinates with `getIndexFromSyntaxPos` and
|
|
341
|
+
* `getLocFromSyntaxPos`.
|
|
342
|
+
*
|
|
343
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
344
|
+
*/
|
|
345
|
+
import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
346
|
+
|
|
347
|
+
const noAndOrAsIf = defineShellRule({
|
|
348
|
+
meta: {
|
|
349
|
+
type: 'problem',
|
|
350
|
+
docs: {
|
|
351
|
+
description: 'disallow `A && B || C` used as if-then-else',
|
|
352
|
+
},
|
|
353
|
+
schema: [],
|
|
354
|
+
messages: {
|
|
355
|
+
andOr: '`A && B || C` is not if-then-else: C also runs when B fails. Use `if A; then B; else C; fi`.',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
create(context) {
|
|
359
|
+
const { sourceCode } = context;
|
|
360
|
+
|
|
361
|
+
/** The two-character operator a `BinaryCmd` record's `OpPos` points at. */
|
|
362
|
+
const operatorOf = (binaryCmd) => {
|
|
363
|
+
const start = sourceCode.getIndexFromSyntaxPos(binaryCmd.OpPos);
|
|
364
|
+
return sourceCode.text.slice(start, start + 2);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
BinaryCmd(node) {
|
|
369
|
+
// `a && b || c` parses as `(a && b) || c`: the `||` command's left
|
|
370
|
+
// statement holds the `&&` command.
|
|
371
|
+
const or = sourceCode.getSyntax(node);
|
|
372
|
+
const and = or.X?.Cmd;
|
|
373
|
+
if (operatorOf(or) !== '||' || and?.Type !== 'BinaryCmd' || operatorOf(and) !== '&&') {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const start = sourceCode.getIndexFromSyntaxPos(and.OpPos);
|
|
378
|
+
context.report({
|
|
379
|
+
loc: {
|
|
380
|
+
start: sourceCode.getLocFromSyntaxPos(and.OpPos),
|
|
381
|
+
end: sourceCode.getLocFromIndex(start + 2),
|
|
382
|
+
},
|
|
383
|
+
messageId: 'andOr',
|
|
384
|
+
});
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
export default noAndOrAsIf;
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Register it the same way as the rule in step 3. For
|
|
394
|
+
|
|
395
|
+
```sh
|
|
396
|
+
[ -f "$f" ] && rm "$f" || echo missing
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
it reports once, on line 1 from column 13 to column 15: exactly the `&&`. A plain `A && B` or
|
|
400
|
+
`A || B` is not reported. This package's test suite runs this case through a real
|
|
401
|
+
`eslint` `Linter`.
|
|
402
|
+
|
|
403
|
+
## 7. Using sh-ast's own operations and types
|
|
404
|
+
|
|
405
|
+
The records `getSyntax` returns are sh-ast records. A rule that needs sh-ast's own operations or
|
|
406
|
+
types for them (to `walk` a record, print an edited copy with `printSync`, run `enumerateCommands`
|
|
407
|
+
over a tree, or name a record type such as `BinaryCmd`) imports them from the plugin's subpaths,
|
|
408
|
+
never from `sh-ast` directly:
|
|
409
|
+
|
|
410
|
+
- `@eslint-sh/eslint-plugin/syntax` (or `eslint-sh/syntax`) has the complete public surface of
|
|
411
|
+
`sh-ast`: `parseSync`, `walk`, `printSync`, `PrintOptions`, and the record types such as `Node`,
|
|
412
|
+
`Word`, `CallExpr`, `SyntaxPos` and `GoString`.
|
|
413
|
+
- `@eslint-sh/eslint-plugin/syntax/analyze` (or `eslint-sh/syntax/analyze`) has the complete
|
|
414
|
+
public surface of `sh-ast/analyze`: `enumerateCommands`, `resolveArgv0` (its `chainIndices`
|
|
415
|
+
locate a resolved command's arguments in the site's `argv`), `DEFAULT_TRANSPARENT_WRAPPERS`,
|
|
416
|
+
`WrapperSpec`, the named operator constants with `rawOperator`, and a raw `resolveWord` that
|
|
417
|
+
takes a sh-ast `Node` rather than a lint-view `ShellNode`.
|
|
418
|
+
|
|
419
|
+
Both come from the plugin's own `sh-ast` dependency, the same module instance the language parses
|
|
420
|
+
with, so you don't install `sh-ast` and its version always matches the records `getSyntax` returns.
|
|
421
|
+
They track the plugin's pinned `sh-ast` minor: an `sh-ast` 0.x minor bump is a minor release of the
|
|
422
|
+
plugin.
|
|
423
|
+
|
|
424
|
+
## 8. Printed fixes
|
|
425
|
+
|
|
426
|
+
Some fixes are easier to describe as an edit to the syntax than as new text. Turning
|
|
427
|
+
`` `date` `` into `$(date)` by hand means re-escaping any nested backticks and backslashes; on the
|
|
428
|
+
syntax it is one field, `CmdSubst.Backquotes`. Two `context.sourceCode` methods turn such an edit
|
|
429
|
+
into an ESLint fix:
|
|
430
|
+
|
|
431
|
+
- `getEditableSyntax(node)` returns an independent copy of the sh-ast record `node` was parsed
|
|
432
|
+
from. It is an ordinary `structuredClone` of that record, which sh-ast documents as the supported
|
|
433
|
+
way to get an editable copy. Edit the copy freely: the original record that `getSyntax` returns,
|
|
434
|
+
the lint view, and helpers such as `resolveWord` keep seeing the original source. It throws a
|
|
435
|
+
`TypeError` for a node that has no record of its own, such as one you built yourself.
|
|
436
|
+
- `getPrintedReplacement(node, edited, options)` prints `edited` with sh-ast's `printSync` and
|
|
437
|
+
returns `{ range, text }`, ready for `fixer.replaceTextRange(range, text)`. The range is
|
|
438
|
+
`node.range`, or the whole file when `edited` is a `File`. `edited` must be a root `printSync`
|
|
439
|
+
accepts (`File`, `Stmt`, `Word`, `Assign`, a `Command` or a `WordPart`), and output that is not
|
|
440
|
+
valid UTF-8 throws a `TypeError` instead of becoming a fix.
|
|
441
|
+
|
|
442
|
+
`docs/examples/no-backticks-rule.mjs` reports backtick command substitution and fixes it to `$(…)`:
|
|
443
|
+
|
|
444
|
+
```mjs
|
|
445
|
+
/**
|
|
446
|
+
* The "printed fix" custom rule from the custom-rule tutorial
|
|
447
|
+
* (`../custom-rule-tutorial.md`, step 8). Plain, directly-runnable
|
|
448
|
+
* JavaScript; this package's test suite imports this exact file and
|
|
449
|
+
* asserts the tutorial's code fence matches it byte-for-byte.
|
|
450
|
+
*
|
|
451
|
+
* It reports backtick command substitution (`` `date` ``) and fixes it to
|
|
452
|
+
* `$(date)`. Instead of rewriting text by hand, which would have to re-escape
|
|
453
|
+
* nested backticks and backslashes, the fix edits a copy of the substitution's
|
|
454
|
+
* sh-ast record from `getEditableSyntax` and lets sh-ast's printer write the
|
|
455
|
+
* new text with `getPrintedReplacement`. The original syntax every other rule
|
|
456
|
+
* reads stays unchanged.
|
|
457
|
+
*
|
|
458
|
+
* @import { PrintOptions } from '@eslint-sh/eslint-plugin/syntax'
|
|
459
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
460
|
+
*/
|
|
461
|
+
import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Prints nested lines with two spaces per level instead of the printer's
|
|
465
|
+
* default tabs.
|
|
466
|
+
* @type {PrintOptions}
|
|
467
|
+
*/
|
|
468
|
+
const printOptions = { Indent: 2 };
|
|
469
|
+
|
|
470
|
+
const noBackticks = defineShellRule({
|
|
471
|
+
meta: {
|
|
472
|
+
type: 'suggestion',
|
|
473
|
+
fixable: 'code',
|
|
474
|
+
docs: {
|
|
475
|
+
description: 'prefer `$(…)` over backtick command substitution',
|
|
476
|
+
},
|
|
477
|
+
schema: [],
|
|
478
|
+
messages: {
|
|
479
|
+
backticks: 'Use `$(…)` instead of backticks for command substitution.',
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
create(context) {
|
|
483
|
+
const { sourceCode } = context;
|
|
484
|
+
return {
|
|
485
|
+
CmdSubst(node) {
|
|
486
|
+
if (!node.backquotes) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// An independent copy: editing it changes neither the lint view nor
|
|
490
|
+
// the syntax other rules read.
|
|
491
|
+
const copy = sourceCode.getEditableSyntax(node);
|
|
492
|
+
copy.Backquotes = false;
|
|
493
|
+
const { range, text } = sourceCode.getPrintedReplacement(node, copy, printOptions);
|
|
494
|
+
context.report({
|
|
495
|
+
node,
|
|
496
|
+
messageId: 'backticks',
|
|
497
|
+
fix: (fixer) => fixer.replaceTextRange(range, text),
|
|
498
|
+
});
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
export default noBackticks;
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Register it the same way as the rule in step 3. For
|
|
508
|
+
|
|
509
|
+
```sh
|
|
510
|
+
now=`date +%s`
|
|
511
|
+
echo "uid: `id -u` 🎉"
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
it reports both substitutions, and `eslint --fix` rewrites the file to
|
|
515
|
+
|
|
516
|
+
```sh
|
|
517
|
+
now=$(date +%s)
|
|
518
|
+
echo "uid: $(id -u) 🎉"
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
leaving every other character of the file as it was. This package's test suite runs this
|
|
522
|
+
case through a real `eslint` `Linter`.
|
|
523
|
+
|
|
524
|
+
The printer formats the node it prints; it does not copy the original text. Its output starts at
|
|
525
|
+
column 0, and nested lines are indented with tabs, or with `Indent` spaces when `options.Indent` is
|
|
526
|
+
set, as the example does. A `Word` or `WordPart` root, like the `CmdSubst` above, is usually a
|
|
527
|
+
single line, so the fix changes only that node. Replacing a multi-line root nested inside other
|
|
528
|
+
code, such as an `if` statement in a function body, keeps the shell meaning but may change the
|
|
529
|
+
formatting of its lines:
|
|
530
|
+
|
|
531
|
+
```sh
|
|
532
|
+
f() {
|
|
533
|
+
if true; then
|
|
534
|
+
echo new
|
|
535
|
+
fi
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Prefer `Word` and `WordPart` roots for surgical fixes. For a `Stmt` root, the printed text leaves
|
|
540
|
+
out the statement's own leading and trailing comments, which lie outside its range, and keeps its
|
|
541
|
+
`;` terminator, so `a; b` never becomes `a b`. When the edit is to a few tokens of a larger,
|
|
542
|
+
hand-formatted construct, step 9's position-based text edit is the better tool.
|
|
543
|
+
|
|
544
|
+
`PrintOptions`, `Node` and the record types (`CmdSubst`, `Word`, …) are the types of
|
|
545
|
+
`@eslint-sh/eslint-plugin/syntax` (step 7), and so is `printSync`, if you want to print a record
|
|
546
|
+
yourself, for example to compare it with the source before reporting:
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
import { printSync } from '@eslint-sh/eslint-plugin/syntax';
|
|
550
|
+
import type { Node, PrintOptions } from '@eslint-sh/eslint-plugin/syntax';
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
## 9. Position-based text edits
|
|
554
|
+
|
|
555
|
+
A printed fix replaces a node's whole range with the printer's rendering of it. That is the right
|
|
556
|
+
trade when the new text is hard to write by hand, as with re-escaping nested backticks. It is the
|
|
557
|
+
wrong one when the edit touches a few tokens of a node that spans several lines, because the
|
|
558
|
+
printer reflows everything else in that range:
|
|
559
|
+
|
|
560
|
+
- nested lines, `\` continuation lines included, are re-indented with tabs by default or
|
|
561
|
+
`options.Indent` spaces, whatever the original used;
|
|
562
|
+
- spacing the printer has its own opinion about is normalized, such as the gap before a trailing
|
|
563
|
+
comment;
|
|
564
|
+
- every line in the range changes, so the fix shows up in a diff as a rewrite of the whole node.
|
|
565
|
+
|
|
566
|
+
Use a position-based text edit instead whenever you know which characters to change: replace or
|
|
567
|
+
remove just that span with `fixer.replaceTextRange` or `fixer.removeRange`, and leave the rest of
|
|
568
|
+
the file byte-for-byte as it was. The positions come from the sh-ast record (step 6):
|
|
569
|
+
`getSyntax(node)` gives the record, and `getIndexFromSyntaxPos(pos)` converts each of its
|
|
570
|
+
UTF-8-byte positions into the string offset a fixer takes.
|
|
571
|
+
|
|
572
|
+
`docs/examples/no-function-keyword-rule.mjs` rewrites the bash-only `function name {` to the
|
|
573
|
+
portable `name() {` by replacing only the text from `function` through the name:
|
|
574
|
+
|
|
575
|
+
```mjs
|
|
576
|
+
/**
|
|
577
|
+
* The "position-based text edit" custom rule from the custom-rule tutorial
|
|
578
|
+
* (`../custom-rule-tutorial.md`, step 9). Plain, directly-runnable
|
|
579
|
+
* JavaScript; this package's test suite imports this exact file and
|
|
580
|
+
* asserts the tutorial's code fence matches it byte-for-byte.
|
|
581
|
+
*
|
|
582
|
+
* It reports the bash-only `function name {` form and fixes it to the
|
|
583
|
+
* portable `name() {`. The fix replaces only the text from the `function`
|
|
584
|
+
* keyword through the name, so the body keeps its exact layout, comments and
|
|
585
|
+
* all; printing the whole edited function would re-indent the body instead.
|
|
586
|
+
* The two ends of that text come from the sh-ast record's own positions,
|
|
587
|
+
* converted to string offsets with `getIndexFromSyntaxPos` (sh-ast positions
|
|
588
|
+
* count UTF-8 bytes).
|
|
589
|
+
*
|
|
590
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
591
|
+
*/
|
|
592
|
+
import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
593
|
+
|
|
594
|
+
const noFunctionKeyword = defineShellRule({
|
|
595
|
+
meta: {
|
|
596
|
+
type: 'suggestion',
|
|
597
|
+
fixable: 'code',
|
|
598
|
+
docs: {
|
|
599
|
+
description: 'prefer `name() { … }` over the `function` keyword',
|
|
600
|
+
},
|
|
601
|
+
schema: [],
|
|
602
|
+
messages: {
|
|
603
|
+
useParens: 'Declare `{{name}}` as `{{name}}() { … }`, without the `function` keyword.',
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
create(context) {
|
|
607
|
+
const { sourceCode } = context;
|
|
608
|
+
return {
|
|
609
|
+
FuncDecl(node) {
|
|
610
|
+
const decl = sourceCode.getSyntax(node);
|
|
611
|
+
// Only `function name {`: not `function name() {`, `name() {`, or
|
|
612
|
+
// zsh's several-name form.
|
|
613
|
+
if (!decl.RsrvWord || decl.Parens || decl.Name === null || decl.Names !== null) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// A name that is not valid UTF-8 arrives as bytes, not a string.
|
|
617
|
+
const name = decl.Name.Value;
|
|
618
|
+
if (typeof name !== 'string') {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const start = sourceCode.getIndexFromSyntaxPos(decl.Position);
|
|
622
|
+
const end = sourceCode.getIndexFromSyntaxPos(decl.Name.ValueEnd);
|
|
623
|
+
context.report({
|
|
624
|
+
node,
|
|
625
|
+
messageId: 'useParens',
|
|
626
|
+
data: { name },
|
|
627
|
+
fix: (fixer) => fixer.replaceTextRange([start, end], `${name}()`),
|
|
628
|
+
});
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
export default noFunctionKeyword;
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Register it the same way as the rule in step 3. For
|
|
638
|
+
|
|
639
|
+
```sh
|
|
640
|
+
function greet {
|
|
641
|
+
# Say hello.
|
|
642
|
+
printf '%s\n' \
|
|
643
|
+
"hello, $1" # to stdout
|
|
644
|
+
}
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
`eslint --fix` changes only the first line:
|
|
648
|
+
|
|
649
|
+
```sh
|
|
650
|
+
greet() {
|
|
651
|
+
# Say hello.
|
|
652
|
+
printf '%s\n' \
|
|
653
|
+
"hello, $1" # to stdout
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The printed-fix version of the same rule (flip `RsrvWord` and `Parens` on a `getEditableSyntax`
|
|
658
|
+
copy, then `getPrintedReplacement(node, copy, { Indent: 4 })`) replaces the whole function, and
|
|
659
|
+
even with `Indent` matching the source it does not keep the original layout (with the default
|
|
660
|
+
options, the four-space indentation would also become tabs):
|
|
661
|
+
|
|
662
|
+
```sh
|
|
663
|
+
greet() {
|
|
664
|
+
# Say hello.
|
|
665
|
+
printf '%s\n' \
|
|
666
|
+
"hello, $1" # to stdout
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
This package's test suite runs both versions through a real `eslint` `Linter`, including
|
|
671
|
+
on a file whose function name and body contain multibyte text.
|
|
672
|
+
|
|
673
|
+
## 10. Writing rules in TypeScript
|
|
674
|
+
|
|
675
|
+
`defineShellRule` and the types around it are exported from `@eslint-sh/eslint-plugin`, so a
|
|
676
|
+
TypeScript rule needs no `@eslint/core` import and no rule types of its own:
|
|
677
|
+
|
|
678
|
+
- `defineShellRule<Options, MessageIds>(…)` takes the options tuple and message ids as type
|
|
679
|
+
arguments, or infers the message ids from `meta.messages` when you leave them out. Either way,
|
|
680
|
+
`context.options` has your options' type, and `context.report` accepts only declared message ids.
|
|
681
|
+
- `context.sourceCode` is `ShellSourceCode`, so `getCommandSites()`, `getSyntax()` and the other
|
|
682
|
+
helpers are typed without a cast, and a listener keyed by a node type (`CallExpr`, `Word:exit`)
|
|
683
|
+
receives that node type, so its fields (`args`, `parts`, `cmd`, …) are typed; other selectors
|
|
684
|
+
receive `ShellNode`, which you narrow on `type`. See [Reading nodes](./reading-nodes.md).
|
|
685
|
+
- `ShellRuleContext<Options, MessageIds>` names the context for a helper outside `create()`, and
|
|
686
|
+
`ShellRule<Options, MessageIds>` names what `defineShellRule` returns. A `ShellRule` always
|
|
687
|
+
declares `meta.languages: ['sh/sh']`: an object written by hand without it is a type error.
|
|
688
|
+
- `ShellRuleSpec` names the object you pass to `defineShellRule`: the rule before the helper adds
|
|
689
|
+
`meta.languages`. (`ShellRule`, the finished rule, is what ESLint calls a rule definition.)
|
|
690
|
+
`ShellRuleMeta` and `ShellRuleVisitor` name its `meta` and the listener map, if you need to
|
|
691
|
+
spell them.
|
|
692
|
+
|
|
693
|
+
`docs/examples/no-listed-commands-rule.ts`:
|
|
694
|
+
|
|
695
|
+
```ts
|
|
696
|
+
/**
|
|
697
|
+
* The TypeScript custom rule from the custom-rule tutorial
|
|
698
|
+
* (`../custom-rule-tutorial.md`, step 10). This package's test suite
|
|
699
|
+
* asserts the tutorial's code fence matches this file byte-for-byte,
|
|
700
|
+
* type-checks it against the package's published declarations, and runs it
|
|
701
|
+
* through a real `eslint` `Linter`.
|
|
702
|
+
*
|
|
703
|
+
* It reports any command named in its `commands` option, including through a
|
|
704
|
+
* wrapper such as `sudo`. Every type comes from `@eslint-sh/eslint-plugin`:
|
|
705
|
+
* the options and message ids passed to `defineShellRule` flow into
|
|
706
|
+
* `context.options` and `context.report`, and `ShellRuleContext` types the
|
|
707
|
+
* helper that reports outside `create()`.
|
|
708
|
+
*
|
|
709
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
710
|
+
*/
|
|
711
|
+
import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
712
|
+
import type { ShellCommandSite, ShellRuleContext } from '@eslint-sh/eslint-plugin';
|
|
713
|
+
|
|
714
|
+
/** The rule's one options object; `commands` defaults to `[]`. */
|
|
715
|
+
type Options = [{ commands?: string[] }?];
|
|
716
|
+
|
|
717
|
+
/** Reports `site` when the command it runs, after wrappers, is in `listed`. */
|
|
718
|
+
function reportIfListed(
|
|
719
|
+
context: ShellRuleContext<Options, 'listed'>,
|
|
720
|
+
site: ShellCommandSite,
|
|
721
|
+
listed: ReadonlySet<string>,
|
|
722
|
+
): void {
|
|
723
|
+
const { effective } = site.resolveInvokedCommand();
|
|
724
|
+
if (effective.static && listed.has(effective.text)) {
|
|
725
|
+
context.report({ node: site.node, messageId: 'listed', data: { name: effective.text } });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const noListedCommands = defineShellRule<Options, 'listed'>({
|
|
730
|
+
meta: {
|
|
731
|
+
type: 'problem',
|
|
732
|
+
docs: {
|
|
733
|
+
description: 'disallow the commands named in the `commands` option',
|
|
734
|
+
},
|
|
735
|
+
schema: [
|
|
736
|
+
{
|
|
737
|
+
type: 'object',
|
|
738
|
+
properties: { commands: { type: 'array', items: { type: 'string' } } },
|
|
739
|
+
additionalProperties: false,
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
messages: {
|
|
743
|
+
listed: '`{{name}}` is not allowed in this project.',
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
create(context) {
|
|
747
|
+
const listed = new Set(context.options[0]?.commands ?? []);
|
|
748
|
+
return {
|
|
749
|
+
File() {
|
|
750
|
+
for (const site of context.sourceCode.getCommandSites()) {
|
|
751
|
+
reportIfListed(context, site, listed);
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
export default noListedCommands;
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Enable it with its options, the same way as the rule in step 3:
|
|
762
|
+
`'my-rules/no-listed-commands': ['error', { commands: ['telnet'] }]`. For `sudo telnet example.com`
|
|
763
|
+
it reports `` `telnet` is not allowed in this project. ``, because `resolveInvokedCommand()` sees
|
|
764
|
+
through `sudo`.
|
|
765
|
+
|
|
766
|
+
## 11. Test your rule
|
|
767
|
+
|
|
768
|
+
ESLint's `RuleTester` runs a rule against `valid` and `invalid` code, and it works with this plugin.
|
|
769
|
+
[`docs/examples/no-backticks-rule-tester.mjs`](./examples/no-backticks-rule-tester.mjs) tests the
|
|
770
|
+
step 8 rule, including its fix, and a second rule that only offers a suggestion:
|
|
771
|
+
|
|
772
|
+
```mjs
|
|
773
|
+
/**
|
|
774
|
+
* The "test your rule" example from the custom-rule tutorial
|
|
775
|
+
* (`../custom-rule-tutorial.md`, step 11). It tests the step 8 rule
|
|
776
|
+
* (`./no-backticks-rule.mjs`) with ESLint's own `RuleTester`, and a small
|
|
777
|
+
* inline rule that offers a suggestion rather than an automatic fix.
|
|
778
|
+
*
|
|
779
|
+
* `RuleTester` needs two settings to run shell rules: the plugin registered
|
|
780
|
+
* under `plugins`, and `language: 'sh/sh'`. Leaving either out
|
|
781
|
+
* makes `RuleTester` throw a configuration error before it checks any case;
|
|
782
|
+
* the tutorial quotes both messages.
|
|
783
|
+
*
|
|
784
|
+
* Run it with `node no-backticks-rule-tester.mjs`. `RuleTester` uses
|
|
785
|
+
* `describe`/`it` when the test runner defines them, and otherwise throws on
|
|
786
|
+
* the first failing case, so a quiet exit means every case passed.
|
|
787
|
+
*
|
|
788
|
+
* This file and `./no-backticks-rule.mjs` ship in the `@eslint-sh/eslint-plugin`
|
|
789
|
+
* package only (`docs/examples/`); the umbrella `eslint-sh` package does not
|
|
790
|
+
* include them. To import from the umbrella instead, change the plugin import
|
|
791
|
+
* below and the one in `./no-backticks-rule.mjs` to `eslint-sh`.
|
|
792
|
+
*
|
|
793
|
+
* @see ../custom-rule-tutorial.md — the tutorial this example backs
|
|
794
|
+
* @see https://eslint.org/docs/latest/integrate/nodejs-api#ruletester
|
|
795
|
+
*/
|
|
796
|
+
import { RuleTester } from 'eslint';
|
|
797
|
+
import sh, { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
798
|
+
import noBackticks from './no-backticks-rule.mjs';
|
|
799
|
+
|
|
800
|
+
const tester = new RuleTester({
|
|
801
|
+
plugins: { sh },
|
|
802
|
+
language: 'sh/sh',
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
tester.run('no-backticks', noBackticks, {
|
|
806
|
+
valid: [
|
|
807
|
+
// An empty file, and a command substitution that already uses `$(…)`.
|
|
808
|
+
{ code: '' },
|
|
809
|
+
{ code: 'now=$(date +%s)\n' },
|
|
810
|
+
],
|
|
811
|
+
invalid: [
|
|
812
|
+
{
|
|
813
|
+
code: 'now=`date +%s`\n',
|
|
814
|
+
// `messageId` checks which message fired; `line` and `column` check where.
|
|
815
|
+
errors: [{ messageId: 'backticks', line: 1, column: 5 }],
|
|
816
|
+
// `output` is the file after ESLint applies the fix.
|
|
817
|
+
output: 'now=$(date +%s)\n',
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
// Multibyte text before and inside the substitutions.
|
|
821
|
+
code: 'echo "héllo `id -u` 🎉" `date`\n',
|
|
822
|
+
errors: [{ messageId: 'backticks' }, { messageId: 'backticks' }],
|
|
823
|
+
output: 'echo "héllo $(id -u) 🎉" $(date)\n',
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* A rule that only suggests: it reports `which` and offers `command -v`, which
|
|
830
|
+
* the reader chooses to apply. A rule with suggestions declares
|
|
831
|
+
* `hasSuggestions: true`.
|
|
832
|
+
*/
|
|
833
|
+
const noWhich = defineShellRule({
|
|
834
|
+
meta: {
|
|
835
|
+
type: 'suggestion',
|
|
836
|
+
hasSuggestions: true,
|
|
837
|
+
docs: { description: 'prefer `command -v` over `which`' },
|
|
838
|
+
schema: [],
|
|
839
|
+
messages: {
|
|
840
|
+
noWhich: '`which` is not portable.',
|
|
841
|
+
useCommandV: 'Use `command -v` instead.',
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
create(context) {
|
|
845
|
+
return {
|
|
846
|
+
CallExpr(node) {
|
|
847
|
+
const [name] = node.args ?? [];
|
|
848
|
+
if (name === undefined || context.sourceCode.getText(name) !== 'which') {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
context.report({
|
|
852
|
+
node: name,
|
|
853
|
+
messageId: 'noWhich',
|
|
854
|
+
suggest: [
|
|
855
|
+
{ messageId: 'useCommandV', fix: (fixer) => fixer.replaceText(name, 'command -v') },
|
|
856
|
+
],
|
|
857
|
+
});
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
tester.run('no-which', noWhich, {
|
|
864
|
+
valid: [{ code: 'command -v ls\n' }],
|
|
865
|
+
invalid: [
|
|
866
|
+
{
|
|
867
|
+
code: 'which ls\n',
|
|
868
|
+
// `output: null` asserts that the rule has no automatic fix.
|
|
869
|
+
output: null,
|
|
870
|
+
errors: [
|
|
871
|
+
{
|
|
872
|
+
messageId: 'noWhich',
|
|
873
|
+
suggestions: [{ messageId: 'useCommandV', output: 'command -v ls\n' }],
|
|
874
|
+
},
|
|
875
|
+
],
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
Run it with `node no-backticks-rule-tester.mjs` from a folder that has `eslint`, the plugin and the
|
|
883
|
+
example files installed or copied in. `RuleTester` uses `describe` and `it` when your test runner
|
|
884
|
+
defines them (Vitest with `globals: true`, Mocha, Jest), and otherwise runs each case in turn and
|
|
885
|
+
throws on the first failure, so a quiet exit means every case passed. Each `tester.run(name, rule,
|
|
886
|
+
cases)` takes:
|
|
887
|
+
|
|
888
|
+
- `valid`: code the rule must not report.
|
|
889
|
+
- `invalid`: code the rule must report. `errors` lists each report by `messageId`, and may add
|
|
890
|
+
`line`, `column` and the other location fields. `output` is the file after ESLint applies the
|
|
891
|
+
rule's fix, and is how you test a fixer; for a rule with no fix, set `output: null`.
|
|
892
|
+
- `suggestions` inside an error lists the suggestions that report offers, each with its `messageId`
|
|
893
|
+
and the `output` that applying it produces. A rule with suggestions must declare
|
|
894
|
+
`meta.hasSuggestions: true`.
|
|
895
|
+
|
|
896
|
+
Put multibyte text (`é`, `🎉`) in at least one case of a rule that edits text, as the example does:
|
|
897
|
+
ESLint measures fix ranges in string offsets, and a rule that confuses them with bytes passes on
|
|
898
|
+
ASCII and breaks on anything else.
|
|
899
|
+
|
|
900
|
+
### The two options to get right
|
|
901
|
+
|
|
902
|
+
`RuleTester` takes the same options as a flat-config entry, and a shell rule needs both of these:
|
|
903
|
+
|
|
904
|
+
```js
|
|
905
|
+
const tester = new RuleTester({
|
|
906
|
+
plugins: { sh }, // register the plugin, so `sh/sh` resolves
|
|
907
|
+
language: 'sh/sh', // parse each case as shell, not JavaScript
|
|
908
|
+
});
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
Leaving out `language` is the easier mistake: `RuleTester` falls back to JavaScript, and the first
|
|
912
|
+
case fails with
|
|
913
|
+
|
|
914
|
+
```text
|
|
915
|
+
TypeError: ESLint configuration in rule-tester is invalid: Key "rules": The following rules do not support the language "js/js":
|
|
916
|
+
- "rule-to-test/no-backticks"
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
Leaving out `plugins: { sh }` fails with
|
|
920
|
+
|
|
921
|
+
```text
|
|
922
|
+
TypeError: ESLint configuration in rule-tester is invalid: Key "language": Could not find "sh" in plugin "sh".
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
Both appear when the test file runs, before any case is checked. A rule built with `defineShellRule`
|
|
926
|
+
declares `meta.languages: ['sh/sh']`, so ESLint refuses to run it under any other language.
|