@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public rule-authoring helper: the one place a shell rule, built-in or
|
|
3
|
+
* third-party, gets its `meta.languages` and its shell-specific types.
|
|
4
|
+
*
|
|
5
|
+
* ESLint (≥ 10.2) refuses to run a rule on a language its `meta.languages`
|
|
6
|
+
* does not list, which turns "a shell rule was enabled for a JavaScript file"
|
|
7
|
+
* (usually a config entry missing `language: 'sh/sh'`) into a configuration
|
|
8
|
+
* error naming the rule. A rule that omits `meta.languages` fails open: ESLint
|
|
9
|
+
* applies it everywhere and the user sees a misleading JavaScript `Parsing
|
|
10
|
+
* error` instead. {@link defineShellRule} therefore owns the field, so no rule
|
|
11
|
+
* built with it can omit or mistype it, and the types forbid an author from
|
|
12
|
+
* writing it at all.
|
|
13
|
+
*
|
|
14
|
+
* It also fixes the types a rule sees, so authors need no `@eslint/core`
|
|
15
|
+
* import of their own: `context.sourceCode` is {@link ShellSourceCode} and
|
|
16
|
+
* visitors receive {@link ShellNode} lint-view nodes — `type` discriminators,
|
|
17
|
+
* lowercase field names, UTF-16 `range`/`loc` — never the faithful `sh-ast`
|
|
18
|
+
* records the view is built from.
|
|
19
|
+
*
|
|
20
|
+
* The plugin's own built-in rules are built through this same helper (via the
|
|
21
|
+
* internal `createRule`, which only adds their docs URLs), so the guarantee a
|
|
22
|
+
* third-party rule gets is the one the built-in rules are tested against.
|
|
23
|
+
*/
|
|
24
|
+
import type { RuleContext, RuleDefinition, RulesMeta } from '@eslint/core';
|
|
25
|
+
import type { NormalizedShellLanguageOptions } from '../language/shell-language-options.js';
|
|
26
|
+
import type { ShellSourceCode } from '../language/shell-source-code.js';
|
|
27
|
+
import type { ShellNode, ShellNodeOf, ShellNodeType } from '../language/syntax-view.js';
|
|
28
|
+
/**
|
|
29
|
+
* A listener for a node of type `Node`. It is declared with method syntax on
|
|
30
|
+
* purpose: method parameters are checked bivariantly, which lets a listener
|
|
31
|
+
* for one node type sit alongside the union-typed listener every other
|
|
32
|
+
* selector key gets (see {@link ShellRuleVisitor}) without a type error.
|
|
33
|
+
*
|
|
34
|
+
* Exported so a helper that builds listeners for several rules can name the
|
|
35
|
+
* type it returns; `node` is the matched node and `parent` its parent
|
|
36
|
+
* (`undefined` for the root `File`).
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
export type ShellListener<Node extends ShellNode = ShellNode> = {
|
|
41
|
+
listener(node: Node, parent: ShellNode | undefined): void;
|
|
42
|
+
}['listener'];
|
|
43
|
+
/**
|
|
44
|
+
* The listeners whose key names one node type, or that node type with the
|
|
45
|
+
* `:exit` suffix, each receiving exactly that node type. The key is
|
|
46
|
+
* unambiguous in both forms (`CallExpr`, `CallExpr:exit`), so a listener
|
|
47
|
+
* needs no `node.type` guard. Wildcard (`*`, `*:exit`) and attribute or
|
|
48
|
+
* class selectors name no single type and fall under the index signature.
|
|
49
|
+
*
|
|
50
|
+
* Exported because {@link ShellRuleVisitor} extends it.
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
export type ShellTypedListeners = {
|
|
55
|
+
[Type in ShellNodeType as Type | `${Type}:exit`]?: ShellListener<ShellNodeOf<Type>>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The listener map a shell rule's `create()` returns. Keys are node types or
|
|
59
|
+
* esquery selectors written against the lint view's spelling (for example
|
|
60
|
+
* `CallExpr[args.0.parts.0.value='jq']` or the `:command` selector class),
|
|
61
|
+
* optionally suffixed with `:exit`. ESLint calls each listener with the
|
|
62
|
+
* matched node and its parent (the parent is `undefined` for the root
|
|
63
|
+
* `File`).
|
|
64
|
+
*
|
|
65
|
+
* A key that is exactly a node type, with or without `:exit` (`CallExpr`,
|
|
66
|
+
* `Word:exit`), types its listener's node as that node type, so
|
|
67
|
+
* `CallExpr(node) { node.args }` reads `args` with no `type` check. Any other
|
|
68
|
+
* selector string (`*`, `CallExpr[…]`, `:command`, `Stmt > CallExpr`) matches
|
|
69
|
+
* nodes of more than one type, or of one type the key does not state, so its
|
|
70
|
+
* listener receives the whole {@link ShellNode} union: narrow it on `type`.
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
export interface ShellRuleVisitor extends ShellTypedListeners {
|
|
75
|
+
[selector: string]: ShellListener | undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The `context` a shell rule's `create()` receives: ESLint's rule context
|
|
79
|
+
* specialized to the shell language, so `context.sourceCode` is
|
|
80
|
+
* {@link ShellSourceCode}, `context.languageOptions` is
|
|
81
|
+
* {@link NormalizedShellLanguageOptions}, `context.options` is `RuleOptions`,
|
|
82
|
+
* and `context.report` takes {@link ShellNode}s and only the `MessageIds` the
|
|
83
|
+
* rule declares. Name it when a helper outside `create()` needs the context.
|
|
84
|
+
*
|
|
85
|
+
* `context.languageOptions` is the object `ShellLanguage.normalizeLanguageOptions`
|
|
86
|
+
* returned, not the user's config input, so `dialect` is always set (`"bash"`
|
|
87
|
+
* by default) and needs no fallback. `context.sourceCode.dialect` carries that
|
|
88
|
+
* same normalized value; read whichever is closer to hand.
|
|
89
|
+
*
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
export type ShellRuleContext<RuleOptions extends unknown[] = [], MessageIds extends string = string> = RuleContext<{
|
|
93
|
+
LangOptions: NormalizedShellLanguageOptions;
|
|
94
|
+
Code: ShellSourceCode;
|
|
95
|
+
RuleOptions: RuleOptions;
|
|
96
|
+
Node: ShellNode;
|
|
97
|
+
MessageIds: MessageIds;
|
|
98
|
+
}>;
|
|
99
|
+
/**
|
|
100
|
+
* The `meta` a rule author writes: every field ESLint's rule `meta` accepts
|
|
101
|
+
* (`type`, `docs`, `schema`, `messages`, `fixable`, `defaultOptions`, …)
|
|
102
|
+
* except the language fields, which {@link defineShellRule} owns. `languages`
|
|
103
|
+
* and its deprecated singular predecessor `language` are typed `never`, so
|
|
104
|
+
* writing either — in particular a language other than `sh/sh` — is a
|
|
105
|
+
* compile error rather than a value the helper silently replaces.
|
|
106
|
+
*
|
|
107
|
+
* @public
|
|
108
|
+
*/
|
|
109
|
+
export type ShellRuleMeta<RuleOptions extends unknown[] = [], MessageIds extends string = string> = Omit<RulesMeta<MessageIds, RuleOptions>, 'languages' | 'language'> & {
|
|
110
|
+
readonly languages?: never;
|
|
111
|
+
readonly language?: never;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* What a rule author hands to {@link defineShellRule}: the rule's `meta` and
|
|
115
|
+
* its `create()`, exactly as in any ESLint rule, minus `meta.languages`. It is
|
|
116
|
+
* the rule before the helper finishes it; {@link ShellRule} is the finished
|
|
117
|
+
* rule (ESLint's `RuleDefinition`), so the input is named a spec, not a
|
|
118
|
+
* definition.
|
|
119
|
+
*
|
|
120
|
+
* @public
|
|
121
|
+
*/
|
|
122
|
+
export interface ShellRuleSpec<RuleOptions extends unknown[] = [], MessageIds extends string = string> {
|
|
123
|
+
/** The rule's metadata; see {@link ShellRuleMeta}. */
|
|
124
|
+
readonly meta: ShellRuleMeta<RuleOptions, MessageIds>;
|
|
125
|
+
/** Builds the rule's listeners for one file from the shell-typed context. */
|
|
126
|
+
readonly create: (context: ShellRuleContext<RuleOptions, MessageIds>) => ShellRuleVisitor;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A finished shell rule, as {@link defineShellRule} returns it: an ordinary
|
|
130
|
+
* ESLint rule definition (it goes in a plugin's `rules` like any other)
|
|
131
|
+
* specialized to the shell language, whose `meta.languages` is always exactly
|
|
132
|
+
* `["sh/sh"]`. The `meta.languages` requirement is part of the type, so a
|
|
133
|
+
* hand-written object that omits it, or names another language, is not a
|
|
134
|
+
* `ShellRule`.
|
|
135
|
+
*
|
|
136
|
+
* @public
|
|
137
|
+
*/
|
|
138
|
+
export type ShellRule<RuleOptions extends unknown[] = [], MessageIds extends string = string> = Omit<RuleDefinition<{
|
|
139
|
+
LangOptions: NormalizedShellLanguageOptions;
|
|
140
|
+
Code: ShellSourceCode;
|
|
141
|
+
RuleOptions: RuleOptions;
|
|
142
|
+
Visitor: ShellRuleVisitor;
|
|
143
|
+
Node: ShellNode;
|
|
144
|
+
MessageIds: MessageIds;
|
|
145
|
+
ExtRuleDocs: unknown;
|
|
146
|
+
}>, 'meta'> & {
|
|
147
|
+
readonly meta: Omit<RulesMeta<MessageIds, RuleOptions>, 'languages'> & {
|
|
148
|
+
languages: ['sh/sh'];
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Builds a shell rule from its spec, setting `meta.languages` to
|
|
153
|
+
* exactly `["sh/sh"]` so ESLint refuses to run the rule on any other language.
|
|
154
|
+
* Everything else in `meta`, and `create`, is kept as written; message ids
|
|
155
|
+
* and option types are inferred from `meta` or may be given explicitly
|
|
156
|
+
* (`defineShellRule<[], 'found'>(…)`).
|
|
157
|
+
*
|
|
158
|
+
* The language fields are written last, so a JavaScript caller that bypasses
|
|
159
|
+
* the types and supplies its own `languages` or `language` still gets
|
|
160
|
+
* exactly `["sh/sh"]` (and no `language`). A JavaScript caller that omits
|
|
161
|
+
* `meta`, which ESLint allows for a rule, is treated as having written
|
|
162
|
+
* `meta: {}`.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
167
|
+
*
|
|
168
|
+
* export const noEval = defineShellRule({
|
|
169
|
+
* meta: { type: 'problem', schema: [], messages: { evalUsed: 'Avoid eval.' } },
|
|
170
|
+
* create(context) {
|
|
171
|
+
* return {
|
|
172
|
+
* CallExpr(node) {
|
|
173
|
+
* if (context.sourceCode.getCommandName(node) === 'eval') {
|
|
174
|
+
* context.report({ node, messageId: 'evalUsed' });
|
|
175
|
+
* }
|
|
176
|
+
* },
|
|
177
|
+
* };
|
|
178
|
+
* },
|
|
179
|
+
* });
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* @public
|
|
183
|
+
*/
|
|
184
|
+
export declare function defineShellRule<RuleOptions extends unknown[] = [], MessageIds extends string = string>(spec: ShellRuleSpec<RuleOptions, MessageIds>): ShellRule<RuleOptions, MessageIds>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `"<plugin namespace>/<language key>"` identifier this plugin's language
|
|
3
|
+
* is selected by in a flat config (`language: "sh/sh"`), and therefore the
|
|
4
|
+
* only entry a shell rule's `meta.languages` holds. ESLint also accepts the
|
|
5
|
+
* plugin's `meta.namespace` as the plugin part, so the value still matches
|
|
6
|
+
* when a consumer registers the plugin under another key.
|
|
7
|
+
*/
|
|
8
|
+
const SHELL_LANGUAGE_ID = 'sh/sh';
|
|
9
|
+
/**
|
|
10
|
+
* Builds a shell rule from its spec, setting `meta.languages` to
|
|
11
|
+
* exactly `["sh/sh"]` so ESLint refuses to run the rule on any other language.
|
|
12
|
+
* Everything else in `meta`, and `create`, is kept as written; message ids
|
|
13
|
+
* and option types are inferred from `meta` or may be given explicitly
|
|
14
|
+
* (`defineShellRule<[], 'found'>(…)`).
|
|
15
|
+
*
|
|
16
|
+
* The language fields are written last, so a JavaScript caller that bypasses
|
|
17
|
+
* the types and supplies its own `languages` or `language` still gets
|
|
18
|
+
* exactly `["sh/sh"]` (and no `language`). A JavaScript caller that omits
|
|
19
|
+
* `meta`, which ESLint allows for a rule, is treated as having written
|
|
20
|
+
* `meta: {}`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { defineShellRule } from '@eslint-sh/eslint-plugin';
|
|
25
|
+
*
|
|
26
|
+
* export const noEval = defineShellRule({
|
|
27
|
+
* meta: { type: 'problem', schema: [], messages: { evalUsed: 'Avoid eval.' } },
|
|
28
|
+
* create(context) {
|
|
29
|
+
* return {
|
|
30
|
+
* CallExpr(node) {
|
|
31
|
+
* if (context.sourceCode.getCommandName(node) === 'eval') {
|
|
32
|
+
* context.report({ node, messageId: 'evalUsed' });
|
|
33
|
+
* }
|
|
34
|
+
* },
|
|
35
|
+
* };
|
|
36
|
+
* },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
export function defineShellRule(spec) {
|
|
43
|
+
// The type requires `meta`, but ESLint accepts a rule without one, so an
|
|
44
|
+
// untyped (JavaScript) caller's missing `meta` is read as empty rather than
|
|
45
|
+
// crashing. The types cannot see that caller, hence the suppression.
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
47
|
+
const meta = spec.meta ?? {};
|
|
48
|
+
// Drop the deprecated singular key an untyped caller may have supplied, so
|
|
49
|
+
// the only language declaration ESLint sees is the one set here.
|
|
50
|
+
const { language: _language, ...authorMeta } = meta;
|
|
51
|
+
const { create } = spec;
|
|
52
|
+
return {
|
|
53
|
+
meta: { ...authorMeta, languages: [SHELL_LANGUAGE_ID] },
|
|
54
|
+
create,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in rule registry: the one and only place this package's rules are
|
|
3
|
+
* listed. The plugin object's `rules` is this record, so a rule is shipped
|
|
4
|
+
* exactly when it appears here.
|
|
5
|
+
*
|
|
6
|
+
* Adding a rule means one line here (`'<rule-name>': <ruleModule>,`, keyed by
|
|
7
|
+
* the same name passed to `createRule`) plus the rule's own files: its module
|
|
8
|
+
* under `src/rules/`, its tests, and its docs page at
|
|
9
|
+
* `docs/rules/<rule-name>.md` (a test fails if a registered rule has no docs
|
|
10
|
+
* page, or a docs page has no registered rule). A namespaced name such as
|
|
11
|
+
* `compat/no-mapfile` keeps its namespace as a directory: the module is
|
|
12
|
+
* `src/rules/compat/no-mapfile.ts` and the page `docs/rules/compat/no-mapfile.md`.
|
|
13
|
+
*
|
|
14
|
+
* Every entry is built with `createRule` (see `./create-rule.ts`), so every
|
|
15
|
+
* entry declares `meta.languages: ["sh/sh"]` and a `meta.docs.url` pointing at
|
|
16
|
+
* its docs page. An empty registry is a valid state: the plugin then ships
|
|
17
|
+
* `rules: {}` and remains usable for consumer-supplied rules.
|
|
18
|
+
*/
|
|
19
|
+
import type { RuleDefinition } from '@eslint/core';
|
|
20
|
+
/**
|
|
21
|
+
* Built-in rules keyed by rule name (without the `sh/` namespace a user writes
|
|
22
|
+
* in their config).
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export declare const rules: Record<string, RuleDefinition>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { noAppendAllRedirect } from './compat/no-append-all-redirect.js';
|
|
2
|
+
import { noAssociativeArrays } from './compat/no-associative-arrays.js';
|
|
3
|
+
import { noBraceSequencePaddingStep } from './compat/no-brace-sequence-padding-step.js';
|
|
4
|
+
import { noCaseAttributes } from './compat/no-case-attributes.js';
|
|
5
|
+
import { noCaseFallthrough } from './compat/no-case-fallthrough.js';
|
|
6
|
+
import { noCaseModification } from './compat/no-case-modification.js';
|
|
7
|
+
import { noCoproc } from './compat/no-coproc.js';
|
|
8
|
+
import { noDeclareG } from './compat/no-declare-g.js';
|
|
9
|
+
import { noDoubleBracket } from './compat/no-double-bracket.js';
|
|
10
|
+
import { noEpochVariables } from './compat/no-epoch-variables.js';
|
|
11
|
+
import { noFdVariable } from './compat/no-fd-variable.js';
|
|
12
|
+
import { noGlobstar } from './compat/no-globstar.js';
|
|
13
|
+
import { noLastpipeSemantics } from './compat/no-lastpipe-semantics.js';
|
|
14
|
+
import { noLet } from './compat/no-let.js';
|
|
15
|
+
import { noMapfile } from './compat/no-mapfile.js';
|
|
16
|
+
import { noNameref } from './compat/no-nameref.js';
|
|
17
|
+
import { noNegativeArrayIndex } from './compat/no-negative-array-index.js';
|
|
18
|
+
import { noNounsetEmptyArray } from './compat/no-nounset-empty-array.js';
|
|
19
|
+
import { noParameterTransformation } from './compat/no-parameter-transformation.js';
|
|
20
|
+
import { noPipeAll } from './compat/no-pipe-all.js';
|
|
21
|
+
import { noPrintfTimeFormat } from './compat/no-printf-time-format.js';
|
|
22
|
+
import { noSource } from './compat/no-source.js';
|
|
23
|
+
import { noTestV } from './compat/no-test-v.js';
|
|
24
|
+
import { noUnicodeEscape } from './compat/no-unicode-escape.js';
|
|
25
|
+
import { noWaitN } from './compat/no-wait-n.js';
|
|
26
|
+
import { maxScriptComplexity } from './max-script-complexity.js';
|
|
27
|
+
import { noDynamicInvocation } from './no-dynamic-invocation.js';
|
|
28
|
+
import { noEval } from './no-eval.js';
|
|
29
|
+
import { noMaskedExitStatus } from './no-masked-exit-status.js';
|
|
30
|
+
import { noSecretEnvReads } from './no-secret-env-reads.js';
|
|
31
|
+
import { noSecretFlags } from './no-secret-flags.js';
|
|
32
|
+
import { noUncheckedCd } from './no-unchecked-cd.js';
|
|
33
|
+
import { noUndeclaredCommands } from './no-undeclared-commands.js';
|
|
34
|
+
import { noUnusedDeclarations } from './no-unused-declarations.js';
|
|
35
|
+
import { preferLocal } from './prefer-local.js';
|
|
36
|
+
import { quoteExpansions } from './quote-expansions.js';
|
|
37
|
+
import { requireNetworkWrapper } from './require-network-wrapper.js';
|
|
38
|
+
import { requireShebang } from './require-shebang.js';
|
|
39
|
+
import { requireStrictMode } from './require-strict-mode.js';
|
|
40
|
+
import { requireTtyGuard } from './require-tty-guard.js';
|
|
41
|
+
import { shebangMatchesDialect } from './shebang-matches-dialect.js';
|
|
42
|
+
/**
|
|
43
|
+
* Built-in rules keyed by rule name (without the `sh/` namespace a user writes
|
|
44
|
+
* in their config).
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export const rules = {
|
|
49
|
+
'compat/no-append-all-redirect': noAppendAllRedirect,
|
|
50
|
+
'compat/no-associative-arrays': noAssociativeArrays,
|
|
51
|
+
'compat/no-brace-sequence-padding-step': noBraceSequencePaddingStep,
|
|
52
|
+
'compat/no-case-attributes': noCaseAttributes,
|
|
53
|
+
'compat/no-case-fallthrough': noCaseFallthrough,
|
|
54
|
+
'compat/no-case-modification': noCaseModification,
|
|
55
|
+
'compat/no-coproc': noCoproc,
|
|
56
|
+
'compat/no-declare-g': noDeclareG,
|
|
57
|
+
'compat/no-double-bracket': noDoubleBracket,
|
|
58
|
+
'compat/no-epoch-variables': noEpochVariables,
|
|
59
|
+
'compat/no-fd-variable': noFdVariable,
|
|
60
|
+
'compat/no-globstar': noGlobstar,
|
|
61
|
+
'compat/no-lastpipe-semantics': noLastpipeSemantics,
|
|
62
|
+
'compat/no-let': noLet,
|
|
63
|
+
'compat/no-mapfile': noMapfile,
|
|
64
|
+
'compat/no-nameref': noNameref,
|
|
65
|
+
'compat/no-negative-array-index': noNegativeArrayIndex,
|
|
66
|
+
'compat/no-nounset-empty-array': noNounsetEmptyArray,
|
|
67
|
+
'compat/no-parameter-transformation': noParameterTransformation,
|
|
68
|
+
'compat/no-pipe-all': noPipeAll,
|
|
69
|
+
'compat/no-printf-time-format': noPrintfTimeFormat,
|
|
70
|
+
'compat/no-source': noSource,
|
|
71
|
+
'compat/no-test-v': noTestV,
|
|
72
|
+
'compat/no-unicode-escape': noUnicodeEscape,
|
|
73
|
+
'compat/no-wait-n': noWaitN,
|
|
74
|
+
'max-script-complexity': maxScriptComplexity,
|
|
75
|
+
'no-dynamic-invocation': noDynamicInvocation,
|
|
76
|
+
'no-eval': noEval,
|
|
77
|
+
'no-masked-exit-status': noMaskedExitStatus,
|
|
78
|
+
'no-secret-env-reads': noSecretEnvReads,
|
|
79
|
+
'no-secret-flags': noSecretFlags,
|
|
80
|
+
'no-unchecked-cd': noUncheckedCd,
|
|
81
|
+
'no-undeclared-commands': noUndeclaredCommands,
|
|
82
|
+
'no-unused-declarations': noUnusedDeclarations,
|
|
83
|
+
'prefer-local': preferLocal,
|
|
84
|
+
'quote-expansions': quoteExpansions,
|
|
85
|
+
'require-network-wrapper': requireNetworkWrapper,
|
|
86
|
+
'require-shebang': requireShebang,
|
|
87
|
+
'require-strict-mode': requireStrictMode,
|
|
88
|
+
'require-tty-guard': requireTtyGuard,
|
|
89
|
+
'shebang-matches-dialect': shebangMatchesDialect,
|
|
90
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule's single options object, as written in a config. Each field is a
|
|
3
|
+
* non-negative integer limit (`0` allows none of that construct), `null` to
|
|
4
|
+
* switch that one limit off, or omitted to take the default.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export interface MaxScriptComplexityOptions {
|
|
9
|
+
/** Most function declarations a file may contain (default 20). */
|
|
10
|
+
readonly maxFunctions?: number | null;
|
|
11
|
+
/** Deepest control-structure nesting a file may contain (default 4). */
|
|
12
|
+
readonly maxNesting?: number | null;
|
|
13
|
+
/** Most statements a file may contain (default 300). */
|
|
14
|
+
readonly maxStatements?: number | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The rule's report kinds, one per limit: `tooManyFunctions`,
|
|
18
|
+
* `tooMuchNesting`, `tooManyStatements`.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export type MaxScriptComplexityMessageId = 'tooManyFunctions' | 'tooMuchNesting' | 'tooManyStatements';
|
|
23
|
+
/**
|
|
24
|
+
* The `sh/max-script-complexity` rule module.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare const maxScriptComplexity: import("./define-shell-rule.js").ShellRule<[MaxScriptComplexityOptions], MaxScriptComplexityMessageId>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createRule } from './create-rule.js';
|
|
2
|
+
/** The registry name of this rule (`sh/max-script-complexity` in a user's config). */
|
|
3
|
+
const RULE_NAME = 'max-script-complexity';
|
|
4
|
+
/** The limits applied when an option is omitted. */
|
|
5
|
+
const DEFAULT_LIMITS = {
|
|
6
|
+
maxFunctions: 20,
|
|
7
|
+
maxNesting: 4,
|
|
8
|
+
maxStatements: 300,
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one configured limit: an omitted field takes the default, while an
|
|
12
|
+
* explicit `null` stays `null` (the limit is off). `??` is deliberately not
|
|
13
|
+
* used because it would turn `null` back into the default.
|
|
14
|
+
*/
|
|
15
|
+
function resolveLimit(configured, fallback) {
|
|
16
|
+
return configured === undefined ? fallback : configured;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Selectors for every construct that adds one nesting level. `elif`/`else`
|
|
20
|
+
* arms are `IfClause` children of an `IfClause` and so are excluded, and a
|
|
21
|
+
* function's body braces (`FuncDecl > Stmt > Block`) are the function's
|
|
22
|
+
* syntax, not a brace group.
|
|
23
|
+
*/
|
|
24
|
+
const NESTING_SELECTORS = [
|
|
25
|
+
'IfClause:not(IfClause > IfClause)',
|
|
26
|
+
'ForClause',
|
|
27
|
+
'WhileClause',
|
|
28
|
+
'CaseClause',
|
|
29
|
+
'Subshell',
|
|
30
|
+
'Block:not(FuncDecl > Stmt > Block)',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Selector for a counted statement: not an operand of `&&`, `||` or `|`, and
|
|
34
|
+
* not a function's body wrapper (the declaration is already one statement).
|
|
35
|
+
*/
|
|
36
|
+
const STATEMENT_SELECTOR = 'Stmt:not(BinaryCmd > Stmt):not(FuncDecl > Stmt)';
|
|
37
|
+
/**
|
|
38
|
+
* The `sh/max-script-complexity` rule module.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export const maxScriptComplexity = createRule({
|
|
43
|
+
name: RULE_NAME,
|
|
44
|
+
meta: {
|
|
45
|
+
type: 'suggestion',
|
|
46
|
+
docs: {
|
|
47
|
+
description: 'Cap the number of functions, the nesting depth and the statement count of a script',
|
|
48
|
+
},
|
|
49
|
+
schema: [
|
|
50
|
+
{
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
maxFunctions: { type: ['integer', 'null'], minimum: 0 },
|
|
54
|
+
maxNesting: { type: ['integer', 'null'], minimum: 0 },
|
|
55
|
+
maxStatements: { type: ['integer', 'null'], minimum: 0 },
|
|
56
|
+
},
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
defaultOptions: [{ ...DEFAULT_LIMITS }],
|
|
61
|
+
messages: {
|
|
62
|
+
tooManyFunctions: 'This script declares {{count}} functions, more than the {{max}} allowed. Split it into smaller scripts.',
|
|
63
|
+
tooMuchNesting: 'This construct is nested {{depth}} levels deep, more than the {{max}} allowed. Flatten it or move the inner part into a function.',
|
|
64
|
+
tooManyStatements: 'This script has more than {{max}} statements. Split it into smaller scripts.',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const { sourceCode } = context;
|
|
69
|
+
const [options] = context.options;
|
|
70
|
+
const maxFunctions = resolveLimit(options.maxFunctions, DEFAULT_LIMITS.maxFunctions);
|
|
71
|
+
const maxNesting = resolveLimit(options.maxNesting, DEFAULT_LIMITS.maxNesting);
|
|
72
|
+
const maxStatements = resolveLimit(options.maxStatements, DEFAULT_LIMITS.maxStatements);
|
|
73
|
+
let functionCount = 0;
|
|
74
|
+
let depth = 0;
|
|
75
|
+
let nestingReported = false;
|
|
76
|
+
let statementCount = 0;
|
|
77
|
+
let statementsReported = false;
|
|
78
|
+
const handlers = {
|
|
79
|
+
FuncDecl() {
|
|
80
|
+
functionCount++;
|
|
81
|
+
},
|
|
82
|
+
[STATEMENT_SELECTOR](node) {
|
|
83
|
+
statementCount++;
|
|
84
|
+
if (maxStatements !== null && statementCount > maxStatements && !statementsReported) {
|
|
85
|
+
statementsReported = true;
|
|
86
|
+
context.report({
|
|
87
|
+
node,
|
|
88
|
+
messageId: 'tooManyStatements',
|
|
89
|
+
data: { max: String(maxStatements) },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
'File:exit'() {
|
|
94
|
+
if (maxFunctions === null || functionCount <= maxFunctions) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Line 1 in full, from the line's own length (as in `require-shebang`).
|
|
98
|
+
const firstLine = sourceCode.lines[0] ?? '';
|
|
99
|
+
context.report({
|
|
100
|
+
loc: {
|
|
101
|
+
start: { line: 1, column: 1 },
|
|
102
|
+
end: { line: 1, column: firstLine.length + 1 },
|
|
103
|
+
},
|
|
104
|
+
messageId: 'tooManyFunctions',
|
|
105
|
+
data: { count: String(functionCount), max: String(maxFunctions) },
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
// Entry and exit must be registered per selector: ESLint reads `:exit`
|
|
110
|
+
// only as a trailing marker on a whole selector string, so a comma-joined
|
|
111
|
+
// list would pair one exit handler with just its last alternative.
|
|
112
|
+
for (const selector of NESTING_SELECTORS) {
|
|
113
|
+
handlers[selector] = (node) => {
|
|
114
|
+
depth++;
|
|
115
|
+
if (maxNesting !== null && depth > maxNesting && !nestingReported) {
|
|
116
|
+
nestingReported = true;
|
|
117
|
+
context.report({
|
|
118
|
+
node,
|
|
119
|
+
messageId: 'tooMuchNesting',
|
|
120
|
+
data: { depth: String(depth), max: String(maxNesting) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
handlers[`${selector}:exit`] = () => {
|
|
125
|
+
depth--;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return handlers;
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message ids this rule reports with: `dynamicInvocation` when the command
|
|
3
|
+
* that runs is not statically known, and `embeddedCommand` when the command
|
|
4
|
+
* runs code embedded in a string (`bash -c …`).
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export type NoDynamicInvocationMessageId = 'dynamicInvocation' | 'embeddedCommand';
|
|
9
|
+
/**
|
|
10
|
+
* The rule's single options object.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export interface NoDynamicInvocationOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Names of dispatcher functions. A dynamic invocation lexically inside a
|
|
17
|
+
* function with one of these names, at any depth, does not report
|
|
18
|
+
* `dynamicInvocation`. Embedded commands still report.
|
|
19
|
+
*/
|
|
20
|
+
readonly allowInFunctions?: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* Command names to treat as transparent wrappers, in addition to the
|
|
23
|
+
* built-in list: the word after one is the command that runs. Each is
|
|
24
|
+
* followed by name only, with no options of its own, so a flag after it
|
|
25
|
+
* makes the effective command unknown. A name already in the built-in list
|
|
26
|
+
* keeps its built-in option handling.
|
|
27
|
+
*/
|
|
28
|
+
readonly allowWrappers?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The `sh/no-dynamic-invocation` rule module.
|
|
32
|
+
*
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export declare const noDynamicInvocation: import("./define-shell-rule.js").ShellRule<[NoDynamicInvocationOptions], NoDynamicInvocationMessageId>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sh/no-dynamic-invocation`: reports every command whose effective command
|
|
3
|
+
* cannot be statically enumerated, so a script's call graph can be read off
|
|
4
|
+
* its source.
|
|
5
|
+
*
|
|
6
|
+
* This is the load-bearing rule for review-and-sign workflows: a reviewer
|
|
7
|
+
* vouching for a script has to know every command it can run. A command word
|
|
8
|
+
* that is only known at run time (`"$cmd" args`, `${tool} x`,
|
|
9
|
+
* `$(pick-tool) x`, `"${cmd[@]}"`) defeats that, and so does a command
|
|
10
|
+
* string handed to another shell (`bash -c "$x"`, `sh -c '…'`).
|
|
11
|
+
*
|
|
12
|
+
* The rule asks, never decides:
|
|
13
|
+
*
|
|
14
|
+
* - Every command in the file comes from `ShellSourceCode.getCommandSites`,
|
|
15
|
+
* so commands nested in command substitutions, process substitutions,
|
|
16
|
+
* pipelines and function bodies are all checked, each once.
|
|
17
|
+
* - Which command each one runs comes from `resolveInvokedCommand`, which
|
|
18
|
+
* follows transparent wrappers (`exec`, `sudo`, `time`, `env`, …) and never
|
|
19
|
+
* guesses through a word it cannot know. A non-static effective command
|
|
20
|
+
* reports. `'embedded-command'` reports as `embeddedCommand`; every other
|
|
21
|
+
* reason (an expansion, a glob, a tilde, an unrecognized wrapper flag, …)
|
|
22
|
+
* reports as `dynamicInvocation`.
|
|
23
|
+
* - The wrappers followed are `sh-ast/analyze`'s defaults, Bash's `builtin`,
|
|
24
|
+
* shells invoked as commands (so `bash -c` is an embedded command and
|
|
25
|
+
* `bash script.sh` runs `script.sh`), and the names in `allowWrappers`.
|
|
26
|
+
*
|
|
27
|
+
* The analysis is syntactic by design: `cmd=ls; "$cmd"` reports, because
|
|
28
|
+
* resolving it would need dataflow. Aliases need no handling: they are not
|
|
29
|
+
* expanded in non-interactive scripts, so an alias name is an ordinary static
|
|
30
|
+
* command word. The rule offers no fix and no suggestion; choosing the
|
|
31
|
+
* command is a human decision.
|
|
32
|
+
*/
|
|
33
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
34
|
+
import { createRule } from './create-rule.js';
|
|
35
|
+
import { BUILTIN_WRAPPER, SHELL_WRAPPER } from './transparent-wrappers.js';
|
|
36
|
+
/** The registry name of this rule (`sh/no-dynamic-invocation` in a user's config). */
|
|
37
|
+
const RULE_NAME = 'no-dynamic-invocation';
|
|
38
|
+
/**
|
|
39
|
+
* The wrappers every invocation is followed through before any configured in
|
|
40
|
+
* `allowWrappers`: `sh-ast/analyze`'s defaults, then Bash's `builtin`, then
|
|
41
|
+
* shells invoked as commands. `resolveArgv0` uses the first entry whose name
|
|
42
|
+
* matches, so the built-in entries take precedence over a user's.
|
|
43
|
+
*/
|
|
44
|
+
const BUILT_IN_WRAPPERS = [
|
|
45
|
+
...DEFAULT_TRANSPARENT_WRAPPERS,
|
|
46
|
+
BUILTIN_WRAPPER,
|
|
47
|
+
SHELL_WRAPPER,
|
|
48
|
+
];
|
|
49
|
+
/** Schema for a list of distinct, non-empty names. */
|
|
50
|
+
const NAME_LIST_SCHEMA = {
|
|
51
|
+
type: 'array',
|
|
52
|
+
items: { type: 'string', minLength: 1 },
|
|
53
|
+
uniqueItems: true,
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Whether `context` places a command inside a function named in `names`, at
|
|
57
|
+
* any depth. A function name that is not valid UTF-8 reaches analysis as
|
|
58
|
+
* bytes (`Uint8Array`), and a configured name is always a string, so such a
|
|
59
|
+
* function never matches.
|
|
60
|
+
*/
|
|
61
|
+
function isInsideFunction(context, names) {
|
|
62
|
+
return context.some((frame) => frame.kind === 'function' && typeof frame.name === 'string' && names.has(frame.name));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `sh/no-dynamic-invocation` rule module.
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export const noDynamicInvocation = createRule({
|
|
70
|
+
name: RULE_NAME,
|
|
71
|
+
meta: {
|
|
72
|
+
type: 'problem',
|
|
73
|
+
docs: {
|
|
74
|
+
description: 'Disallow commands whose executed command cannot be statically determined',
|
|
75
|
+
},
|
|
76
|
+
schema: [
|
|
77
|
+
{
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
allowInFunctions: NAME_LIST_SCHEMA,
|
|
81
|
+
allowWrappers: NAME_LIST_SCHEMA,
|
|
82
|
+
},
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
defaultOptions: [{ allowInFunctions: [], allowWrappers: [] }],
|
|
87
|
+
messages: {
|
|
88
|
+
dynamicInvocation: 'The command this runs is not known until run time, so the script cannot be statically reviewed. Name the command with a literal word instead.',
|
|
89
|
+
embeddedCommand: 'This runs shell code embedded in a string, which cannot be statically reviewed. Write the commands directly instead.',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
create(context) {
|
|
93
|
+
const { sourceCode } = context;
|
|
94
|
+
// `defaultOptions` guarantees the options object; either list may still be omitted in it.
|
|
95
|
+
const [{ allowInFunctions = [], allowWrappers = [] }] = context.options;
|
|
96
|
+
const dispatchers = new Set(allowInFunctions);
|
|
97
|
+
const transparentWrappers = [
|
|
98
|
+
...BUILT_IN_WRAPPERS,
|
|
99
|
+
...allowWrappers.map((name) => ({ names: [name] })),
|
|
100
|
+
];
|
|
101
|
+
return {
|
|
102
|
+
File() {
|
|
103
|
+
for (const site of sourceCode.getCommandSites()) {
|
|
104
|
+
const { effective } = site.resolveInvokedCommand({ transparentWrappers });
|
|
105
|
+
if (effective.static)
|
|
106
|
+
continue;
|
|
107
|
+
if (effective.reason === 'embedded-command') {
|
|
108
|
+
context.report({ node: site.node, messageId: 'embeddedCommand' });
|
|
109
|
+
}
|
|
110
|
+
else if (!isInsideFunction(site.context, dispatchers)) {
|
|
111
|
+
context.report({ node: site.node, messageId: 'dynamicInvocation' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|