@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.
- package/README.md +228 -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 +51 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +53 -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-brace-sequence-padding-step.d.ts +15 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -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 +205 -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 +90 -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 +318 -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 +354 -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 +79 -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-brace-sequence-padding-step.md +90 -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 +131 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +87 -0
- package/docs/untrusted-code.md +440 -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
|
@@ -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).
|
|
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
|
-
|
|
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;
|