@eslint-sh/eslint-plugin 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -17
- package/dist/command-harvest/builtins/bash.d.ts +36 -0
- package/dist/command-harvest/builtins/bash.js +98 -0
- package/dist/command-harvest/builtins/bats.d.ts +45 -0
- package/dist/command-harvest/builtins/bats.js +33 -0
- package/dist/command-harvest/builtins/index.d.ts +18 -0
- package/dist/command-harvest/builtins/index.js +39 -0
- package/dist/command-harvest/builtins/posix.d.ts +51 -0
- package/dist/command-harvest/builtins/posix.js +93 -0
- package/dist/command-harvest/harvest-commands.d.ts +66 -0
- package/dist/command-harvest/harvest-commands.js +97 -0
- package/dist/command-harvest/wrapper-table.d.ts +24 -0
- package/dist/command-harvest/wrapper-table.js +49 -0
- package/dist/configs/agent-authored.d.ts +41 -0
- package/dist/configs/agent-authored.js +33 -0
- package/dist/configs/bash-3-2.d.ts +28 -0
- package/dist/configs/bash-3-2.js +50 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +52 -0
- package/dist/configs/recommended.d.ts +52 -0
- package/dist/configs/recommended.js +34 -0
- package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
- package/dist/declaration-conventions/comment-pragma.js +153 -0
- package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
- package/dist/declaration-conventions/declaration-convention.js +1 -0
- package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
- package/dist/declaration-conventions/read-declared-commands.js +128 -0
- package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
- package/dist/eslint-plugin-syntax.d.ts +27 -0
- package/dist/eslint-plugin.d.ts +1211 -52
- package/dist/index.d.ts +56 -8
- package/dist/index.js +53 -6
- package/dist/language/command-resolution.d.ts +124 -0
- package/dist/language/command-resolution.js +120 -0
- package/dist/language/end-of-input-marker.d.ts +29 -0
- package/dist/language/end-of-input-marker.js +88 -0
- package/dist/language/line-breaks.d.ts +18 -0
- package/dist/language/line-breaks.js +22 -0
- package/dist/language/lint-view-types.d.ts +104 -0
- package/dist/language/lint-view-types.js +1 -0
- package/dist/language/non-shell-file-guard.d.ts +35 -0
- package/dist/language/non-shell-file-guard.js +59 -0
- package/dist/language/selector-classes.d.ts +0 -1
- package/dist/language/selector-classes.js +0 -1
- package/dist/language/shell-language-options.d.ts +7 -1
- package/dist/language/shell-language-options.js +4 -4
- package/dist/language/shell-language.d.ts +10 -2
- package/dist/language/shell-language.js +59 -8
- package/dist/language/shell-source-code.d.ts +342 -5
- package/dist/language/shell-source-code.js +559 -30
- package/dist/language/source-coordinates.d.ts +13 -2
- package/dist/language/source-coordinates.js +68 -14
- package/dist/language/syntax-view.d.ts +60 -39
- package/dist/language/syntax-view.js +200 -45
- package/dist/language/word-resolution.d.ts +4 -3
- package/dist/language/word-resolution.js +4 -3
- package/dist/rules/compat/create-compat-rule.d.ts +59 -0
- package/dist/rules/compat/create-compat-rule.js +65 -0
- package/dist/rules/compat/declaration-options.d.ts +19 -0
- package/dist/rules/compat/declaration-options.js +110 -0
- package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
- package/dist/rules/compat/no-append-all-redirect.js +73 -0
- package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
- package/dist/rules/compat/no-associative-arrays.js +39 -0
- package/dist/rules/compat/no-case-attributes.d.ts +12 -0
- package/dist/rules/compat/no-case-attributes.js +63 -0
- package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
- package/dist/rules/compat/no-case-fallthrough.js +67 -0
- package/dist/rules/compat/no-case-modification.d.ts +35 -0
- package/dist/rules/compat/no-case-modification.js +86 -0
- package/dist/rules/compat/no-coproc.d.ts +12 -0
- package/dist/rules/compat/no-coproc.js +47 -0
- package/dist/rules/compat/no-declare-g.d.ts +12 -0
- package/dist/rules/compat/no-declare-g.js +59 -0
- package/dist/rules/compat/no-double-bracket.d.ts +12 -0
- package/dist/rules/compat/no-double-bracket.js +52 -0
- package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
- package/dist/rules/compat/no-epoch-variables.js +57 -0
- package/dist/rules/compat/no-fd-variable.d.ts +12 -0
- package/dist/rules/compat/no-fd-variable.js +48 -0
- package/dist/rules/compat/no-globstar.d.ts +12 -0
- package/dist/rules/compat/no-globstar.js +39 -0
- package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
- package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
- package/dist/rules/compat/no-let.d.ts +12 -0
- package/dist/rules/compat/no-let.js +62 -0
- package/dist/rules/compat/no-mapfile.d.ts +13 -0
- package/dist/rules/compat/no-mapfile.js +122 -0
- package/dist/rules/compat/no-nameref.d.ts +12 -0
- package/dist/rules/compat/no-nameref.js +44 -0
- package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
- package/dist/rules/compat/no-negative-array-index.js +123 -0
- package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
- package/dist/rules/compat/no-nounset-empty-array.js +158 -0
- package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
- package/dist/rules/compat/no-parameter-transformation.js +123 -0
- package/dist/rules/compat/no-pipe-all.d.ts +13 -0
- package/dist/rules/compat/no-pipe-all.js +68 -0
- package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
- package/dist/rules/compat/no-printf-time-format.js +99 -0
- package/dist/rules/compat/no-source.d.ts +13 -0
- package/dist/rules/compat/no-source.js +103 -0
- package/dist/rules/compat/no-test-v.d.ts +12 -0
- package/dist/rules/compat/no-test-v.js +280 -0
- package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
- package/dist/rules/compat/no-unicode-escape.js +120 -0
- package/dist/rules/compat/no-wait-n.d.ts +12 -0
- package/dist/rules/compat/no-wait-n.js +73 -0
- package/dist/rules/compat/operator-range.d.ts +10 -0
- package/dist/rules/compat/operator-range.js +15 -0
- package/dist/rules/compat/posix-absent-command.d.ts +12 -0
- package/dist/rules/compat/posix-absent-command.js +134 -0
- package/dist/rules/compat/shopt-options.d.ts +9 -0
- package/dist/rules/compat/shopt-options.js +63 -0
- package/dist/rules/compat/version-gates.d.ts +76 -0
- package/dist/rules/compat/version-gates.js +197 -0
- package/dist/rules/create-rule.d.ts +91 -0
- package/dist/rules/create-rule.js +51 -0
- package/dist/rules/create-wrapper-rule.d.ts +166 -0
- package/dist/rules/create-wrapper-rule.js +407 -0
- package/dist/rules/define-shell-rule.d.ts +184 -0
- package/dist/rules/define-shell-rule.js +56 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.js +88 -0
- package/dist/rules/max-script-complexity.d.ts +28 -0
- package/dist/rules/max-script-complexity.js +130 -0
- package/dist/rules/no-dynamic-invocation.d.ts +35 -0
- package/dist/rules/no-dynamic-invocation.js +117 -0
- package/dist/rules/no-eval.d.ts +13 -0
- package/dist/rules/no-eval.js +89 -0
- package/dist/rules/no-masked-exit-status.d.ts +21 -0
- package/dist/rules/no-masked-exit-status.js +667 -0
- package/dist/rules/no-secret-env-reads.d.ts +24 -0
- package/dist/rules/no-secret-env-reads.js +133 -0
- package/dist/rules/no-secret-flags.d.ts +32 -0
- package/dist/rules/no-secret-flags.js +162 -0
- package/dist/rules/no-unchecked-cd.d.ts +44 -0
- package/dist/rules/no-unchecked-cd.js +250 -0
- package/dist/rules/no-undeclared-commands.d.ts +20 -0
- package/dist/rules/no-undeclared-commands.js +115 -0
- package/dist/rules/no-unused-declarations.d.ts +23 -0
- package/dist/rules/no-unused-declarations.js +259 -0
- package/dist/rules/prefer-local.d.ts +16 -0
- package/dist/rules/prefer-local.js +249 -0
- package/dist/rules/quote-expansions.d.ts +13 -0
- package/dist/rules/quote-expansions.js +229 -0
- package/dist/rules/require-network-wrapper.d.ts +40 -0
- package/dist/rules/require-network-wrapper.js +244 -0
- package/dist/rules/require-shebang.d.ts +29 -0
- package/dist/rules/require-shebang.js +101 -0
- package/dist/rules/require-strict-mode.d.ts +52 -0
- package/dist/rules/require-strict-mode.js +177 -0
- package/dist/rules/require-tty-guard.d.ts +13 -0
- package/dist/rules/require-tty-guard.js +289 -0
- package/dist/rules/resolve-declaration-convention.d.ts +96 -0
- package/dist/rules/resolve-declaration-convention.js +109 -0
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +116 -0
- package/dist/rules/set-options.d.ts +109 -0
- package/dist/rules/set-options.js +201 -0
- package/dist/rules/shebang-interpreter.d.ts +18 -0
- package/dist/rules/shebang-interpreter.js +81 -0
- package/dist/rules/shebang-matches-dialect.d.ts +12 -0
- package/dist/rules/shebang-matches-dialect.js +69 -0
- package/dist/rules/transparent-wrappers.d.ts +52 -0
- package/dist/rules/transparent-wrappers.js +99 -0
- package/dist/rules/user-pattern.d.ts +63 -0
- package/dist/rules/user-pattern.js +184 -0
- package/dist/syntax/analyze.d.ts +19 -0
- package/dist/syntax/analyze.js +21 -0
- package/dist/syntax/index.d.ts +23 -0
- package/dist/syntax/index.js +25 -0
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/docs/compat.md +347 -0
- package/docs/custom-rule-tutorial.md +780 -34
- package/docs/declaration-conventions.md +478 -0
- package/docs/examples/audit-wrapper-pack.mjs +15 -0
- package/docs/examples/dep-line-convention.mjs +22 -0
- package/docs/examples/eslint.config.mjs +3 -2
- package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
- package/docs/examples/no-backticks-rule-tester.mjs +106 -0
- package/docs/examples/no-backticks-rule.mjs +60 -0
- package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
- package/docs/examples/no-function-keyword-rule.mjs +59 -0
- package/docs/examples/no-listed-commands-rule.ts +63 -0
- package/docs/examples/no-rm-rf-rule.mjs +26 -15
- package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
- package/docs/examples/sidecar-convention.mjs +38 -0
- package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
- package/docs/examples/sorted-declarations-rule.mjs +52 -0
- package/docs/reading-nodes.md +159 -0
- package/docs/rules/README.md +78 -0
- package/docs/rules/_template.md +83 -0
- package/docs/rules/compat/no-append-all-redirect.md +87 -0
- package/docs/rules/compat/no-associative-arrays.md +95 -0
- package/docs/rules/compat/no-case-attributes.md +82 -0
- package/docs/rules/compat/no-case-fallthrough.md +90 -0
- package/docs/rules/compat/no-case-modification.md +74 -0
- package/docs/rules/compat/no-coproc.md +76 -0
- package/docs/rules/compat/no-declare-g.md +80 -0
- package/docs/rules/compat/no-double-bracket.md +72 -0
- package/docs/rules/compat/no-epoch-variables.md +79 -0
- package/docs/rules/compat/no-fd-variable.md +78 -0
- package/docs/rules/compat/no-globstar.md +74 -0
- package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
- package/docs/rules/compat/no-let.md +78 -0
- package/docs/rules/compat/no-mapfile.md +118 -0
- package/docs/rules/compat/no-nameref.md +81 -0
- package/docs/rules/compat/no-negative-array-index.md +87 -0
- package/docs/rules/compat/no-nounset-empty-array.md +103 -0
- package/docs/rules/compat/no-parameter-transformation.md +94 -0
- package/docs/rules/compat/no-pipe-all.md +85 -0
- package/docs/rules/compat/no-printf-time-format.md +75 -0
- package/docs/rules/compat/no-source.md +98 -0
- package/docs/rules/compat/no-test-v.md +82 -0
- package/docs/rules/compat/no-unicode-escape.md +91 -0
- package/docs/rules/compat/no-wait-n.md +85 -0
- package/docs/rules/max-script-complexity.md +109 -0
- package/docs/rules/no-dynamic-invocation.md +139 -0
- package/docs/rules/no-eval.md +106 -0
- package/docs/rules/no-masked-exit-status.md +239 -0
- package/docs/rules/no-secret-env-reads.md +173 -0
- package/docs/rules/no-secret-flags.md +169 -0
- package/docs/rules/no-unchecked-cd.md +146 -0
- package/docs/rules/no-undeclared-commands.md +141 -0
- package/docs/rules/no-unused-declarations.md +216 -0
- package/docs/rules/prefer-local.md +221 -0
- package/docs/rules/quote-expansions.md +157 -0
- package/docs/rules/require-network-wrapper.md +171 -0
- package/docs/rules/require-shebang.md +157 -0
- package/docs/rules/require-strict-mode.md +253 -0
- package/docs/rules/require-tty-guard.md +128 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +86 -0
- package/docs/wrapper-rules.md +206 -0
- package/package.json +26 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/language/selector-classes.d.ts.map +0 -1
- package/dist/language/selector-classes.js.map +0 -1
- package/dist/language/shell-language-options.d.ts.map +0 -1
- package/dist/language/shell-language-options.js.map +0 -1
- package/dist/language/shell-language.d.ts.map +0 -1
- package/dist/language/shell-language.js.map +0 -1
- package/dist/language/shell-source-code.d.ts.map +0 -1
- package/dist/language/shell-source-code.js.map +0 -1
- package/dist/language/source-coordinates.d.ts.map +0 -1
- package/dist/language/source-coordinates.js.map +0 -1
- package/dist/language/syntax-view.d.ts.map +0 -1
- package/dist/language/syntax-view.js.map +0 -1
- package/dist/language/word-resolution.d.ts.map +0 -1
- package/dist/language/word-resolution.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createWrapperRule`: the public factory for "command X must be run through
|
|
3
|
+
* wrapper Y" rules, such as network calls that must go through a retry or
|
|
4
|
+
* audit wrapper.
|
|
5
|
+
*
|
|
6
|
+
* Which commands need a wrapper, and which wrapper, is project policy, so this
|
|
7
|
+
* package ships no wrapper name. A rule pack binds both by calling the factory
|
|
8
|
+
* (`createWrapperRule({ commands: ['curl'], wrapper: 'with-retry' })`), and
|
|
9
|
+
* `sh/require-network-wrapper` is the built-in instance whose wrapper comes
|
|
10
|
+
* from the user's rule options.
|
|
11
|
+
*
|
|
12
|
+
* "Run through" is decided from `sh-ast/analyze`'s transparent-wrapper
|
|
13
|
+
* resolution, never by reading words locally: each command site's argv0 is
|
|
14
|
+
* followed through the default wrapper table (`sudo`, `env`, `nohup`, …), the
|
|
15
|
+
* bound wrapper, and any command with modeled subcommands, and the resulting
|
|
16
|
+
* chain says what runs and what it runs inside. A listed command is wrapped
|
|
17
|
+
* exactly when the bound wrapper appears earlier in that chain, so
|
|
18
|
+
* `retry curl x` and `retry sudo curl x` are wrapped, while `sudo curl x` and
|
|
19
|
+
* the `curl` in `retry echo "$(curl x)"` are not.
|
|
20
|
+
*
|
|
21
|
+
* Names are matched by basename when a static command word contains a slash,
|
|
22
|
+
* so a path cannot bypass the policy: `/usr/bin/curl x` is `curl`, and
|
|
23
|
+
* `/usr/local/bin/retry curl x` is `curl` run through `retry`. This holds for
|
|
24
|
+
* listed commands, the bound wrapper, and every transparent wrapper followed
|
|
25
|
+
* (`/usr/bin/sudo curl x` is `curl` run through `sudo`).
|
|
26
|
+
*
|
|
27
|
+
* A call to a function the file defines (anywhere in it) is not the external
|
|
28
|
+
* command, so a listed command word that names one is not reported; a listed
|
|
29
|
+
* command behind a wrapper (`command curl x`) runs the external command and
|
|
30
|
+
* still is. Invocations a pack names in `localInvocations` act only locally
|
|
31
|
+
* and are not reported either.
|
|
32
|
+
*
|
|
33
|
+
* Facts that cannot be known statically are never guessed: a site whose chain
|
|
34
|
+
* stops at a dynamic word or an unrecognized wrapper flag before reaching a
|
|
35
|
+
* listed command does not report, and neither does a subcommand that is not
|
|
36
|
+
* static. Commands inside command strings (`bash -c '…'`) are not commands to
|
|
37
|
+
* this analysis and are out of scope.
|
|
38
|
+
*/
|
|
39
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
40
|
+
import { definedFunctionNames } from '../command-harvest/harvest-commands.js';
|
|
41
|
+
import { createRule, packageDocsUrl } from './create-rule.js';
|
|
42
|
+
/**
|
|
43
|
+
* The messages every wrapper rule reports with. Shared so that a rule which
|
|
44
|
+
* delegates to the factory (`sh/require-network-wrapper`) declares exactly the
|
|
45
|
+
* messages the factory's listeners report.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export const WRAPPER_RULE_MESSAGES = {
|
|
50
|
+
unwrappedCommand: '`{{command}}` must be run through `{{wrapper}}`.',
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The rule name `createRule` receives for every factory-made rule. A
|
|
54
|
+
* factory-made rule is not in the registry and always passes its own
|
|
55
|
+
* `docsUrl`, so the name is never used to derive a URL.
|
|
56
|
+
*/
|
|
57
|
+
const WRAPPER_RULE_NAME = 'wrapper-rule';
|
|
58
|
+
/** The default `meta.docs.url` of a factory-made rule: the page documenting the factory. */
|
|
59
|
+
const WRAPPER_RULES_DOCS_URL = packageDocsUrl('wrapper-rules.md');
|
|
60
|
+
/**
|
|
61
|
+
* `name` when it is in `names`; otherwise, when `name` contains a slash, its
|
|
62
|
+
* basename when that is in `names`; otherwise `null`. This is the one place a
|
|
63
|
+
* command word is compared with a configured name, so a path to a configured
|
|
64
|
+
* command (`/usr/bin/curl`, `./bin/curl`) can never bypass the policy.
|
|
65
|
+
*/
|
|
66
|
+
function matchName(name, names) {
|
|
67
|
+
if (names.has(name))
|
|
68
|
+
return name;
|
|
69
|
+
const slash = name.lastIndexOf('/');
|
|
70
|
+
if (slash === -1)
|
|
71
|
+
return null;
|
|
72
|
+
const base = name.slice(slash + 1);
|
|
73
|
+
return names.has(base) ? base : null;
|
|
74
|
+
}
|
|
75
|
+
/** `true` when `value` is a non-null, non-array object. */
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
/** `true` when `value` is a non-empty string. */
|
|
80
|
+
function isNonEmptyString(value) {
|
|
81
|
+
return typeof value === 'string' && value.length > 0;
|
|
82
|
+
}
|
|
83
|
+
/** `true` when `value` is an array of non-empty strings. */
|
|
84
|
+
function isStringArray(value) {
|
|
85
|
+
return (Array.isArray(value) &&
|
|
86
|
+
value.every((item) => typeof item === 'string' && item.length > 0));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Checks `config` and builds its lookup form. Throws `TypeError` naming the
|
|
90
|
+
* offending field, so a malformed binding fails when a rule pack is loaded
|
|
91
|
+
* rather than in the middle of linting a file.
|
|
92
|
+
*/
|
|
93
|
+
function compileWrapperRule(config) {
|
|
94
|
+
const { commands, wrapper, subcommands = {}, localInvocations = {}, docs = {}, } = config;
|
|
95
|
+
if (!isStringArray(commands)) {
|
|
96
|
+
throw new TypeError('createWrapperRule: `commands` must be an array of non-empty strings');
|
|
97
|
+
}
|
|
98
|
+
for (const field of ['url', 'description']) {
|
|
99
|
+
if (docs[field] !== undefined && !isNonEmptyString(docs[field])) {
|
|
100
|
+
throw new TypeError(`createWrapperRule: \`docs.${field}\` must be a non-empty string`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let wrapperSpec;
|
|
104
|
+
if (typeof wrapper === 'string' && wrapper.length > 0) {
|
|
105
|
+
wrapperSpec = DEFAULT_TRANSPARENT_WRAPPERS.find((spec) => spec.names.includes(wrapper)) ?? {
|
|
106
|
+
names: [wrapper],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
else if (typeof wrapper === 'object' &&
|
|
110
|
+
isStringArray(wrapper.names) &&
|
|
111
|
+
wrapper.names.length > 0) {
|
|
112
|
+
wrapperSpec = wrapper;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
throw new TypeError('createWrapperRule: `wrapper` must be a non-empty string or a WrapperSpec with non-empty `names`');
|
|
116
|
+
}
|
|
117
|
+
const subcommandSpecs = [];
|
|
118
|
+
const subcommandNames = new Map();
|
|
119
|
+
for (const [command, restriction] of Object.entries(subcommands)) {
|
|
120
|
+
if (!isStringArray(restriction.names) || restriction.names.length === 0) {
|
|
121
|
+
throw new TypeError(`createWrapperRule: \`subcommands.${command}.names\` must be a non-empty array of non-empty strings`);
|
|
122
|
+
}
|
|
123
|
+
subcommandNames.set(command, new Set(restriction.names));
|
|
124
|
+
// Modeling the command as a transparent wrapper makes `resolveArgv0`
|
|
125
|
+
// locate its subcommand exactly as it locates a wrapped command: the
|
|
126
|
+
// declared options are skipped, and an undeclared flag stops the chain.
|
|
127
|
+
subcommandSpecs.push({
|
|
128
|
+
names: [command],
|
|
129
|
+
...(restriction.noArgFlags && { noArgFlags: restriction.noArgFlags }),
|
|
130
|
+
...(restriction.argFlags && { argFlags: restriction.argFlags }),
|
|
131
|
+
...(restriction.stopsChainFlags && { stopsChainFlags: restriction.stopsChainFlags }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const [label, local] of Object.entries(localInvocations)) {
|
|
135
|
+
const where = `localInvocations.${label}`;
|
|
136
|
+
if (!isRecord(local)) {
|
|
137
|
+
throw new TypeError(`createWrapperRule: \`${where}\` must be an object`);
|
|
138
|
+
}
|
|
139
|
+
const { argLists, localOperand } = local;
|
|
140
|
+
if (argLists !== undefined &&
|
|
141
|
+
!(Array.isArray(argLists) &&
|
|
142
|
+
argLists.every((list) => Array.isArray(list) && isStringArray(list)))) {
|
|
143
|
+
throw new TypeError(`createWrapperRule: \`${where}.argLists\` must be an array of arrays of non-empty strings`);
|
|
144
|
+
}
|
|
145
|
+
if (localOperand === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
if (!isRecord(localOperand)) {
|
|
148
|
+
throw new TypeError(`createWrapperRule: \`${where}.localOperand\` must be an object`);
|
|
149
|
+
}
|
|
150
|
+
for (const field of ['localPrefixes', 'noArgFlags', 'argFlags']) {
|
|
151
|
+
const value = localOperand[field];
|
|
152
|
+
const valid = field === 'localPrefixes'
|
|
153
|
+
? isStringArray(value)
|
|
154
|
+
: value === undefined || isStringArray(value);
|
|
155
|
+
if (!valid) {
|
|
156
|
+
throw new TypeError(`createWrapperRule: \`${where}.localOperand.${field}\` must be an array of non-empty strings`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// `resolveArgv0` uses the first entry whose names match, so an entry this
|
|
161
|
+
// rule supplies replaces any default entry with the same name rather than
|
|
162
|
+
// competing with it.
|
|
163
|
+
const supplied = [wrapperSpec, ...subcommandSpecs];
|
|
164
|
+
const suppliedNames = new Set(supplied.flatMap((spec) => spec.names));
|
|
165
|
+
const transparentWrappers = [
|
|
166
|
+
...supplied,
|
|
167
|
+
...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => !spec.names.some((name) => suppliedNames.has(name))),
|
|
168
|
+
];
|
|
169
|
+
const wrapperLabel = wrapperSpec.names.join('` or `');
|
|
170
|
+
const listed = commands.map((command) => `\`${command}\``).join(', ');
|
|
171
|
+
return {
|
|
172
|
+
commands: new Set(commands),
|
|
173
|
+
docsUrl: docs.url ?? WRAPPER_RULES_DOCS_URL,
|
|
174
|
+
description: docs.description ?? `Require ${listed} to be run through \`${wrapperLabel}\``,
|
|
175
|
+
wrapperNames: new Set(wrapperSpec.names),
|
|
176
|
+
wrapperLabel,
|
|
177
|
+
subcommands: subcommandNames,
|
|
178
|
+
localInvocations: new Map(Object.entries(localInvocations)),
|
|
179
|
+
transparentWrappers,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** The static text of a chain word, or `null` when it is not static. */
|
|
183
|
+
function staticText(word) {
|
|
184
|
+
return word?.static === true ? word.text : null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolves `site`'s chain through `compiled`'s wrapper table, following a
|
|
188
|
+
* wrapper invoked by path as the wrapper it names.
|
|
189
|
+
*
|
|
190
|
+
* `resolveArgv0` matches wrapper names exactly, so a path to a wrapper
|
|
191
|
+
* (`/usr/local/bin/retry`) ends the chain as if it were the command run. When
|
|
192
|
+
* the chain ends at a static word with a slash whose basename names a table
|
|
193
|
+
* entry, the site is resolved again with that entry also recognized under the
|
|
194
|
+
* exact path text, so its options and wrapped command are located by the
|
|
195
|
+
* same analysis. Each pass adds one new name drawn from the site's own argv,
|
|
196
|
+
* so the loop ends.
|
|
197
|
+
*/
|
|
198
|
+
function resolveChain(compiled, site) {
|
|
199
|
+
let table = compiled.transparentWrappers;
|
|
200
|
+
for (;;) {
|
|
201
|
+
const resolution = site.resolveInvokedCommand({ transparentWrappers: table });
|
|
202
|
+
const last = staticText(resolution.chain.at(-1));
|
|
203
|
+
if (last === null || table.some((spec) => spec.names.includes(last)))
|
|
204
|
+
return resolution;
|
|
205
|
+
const spec = compiled.transparentWrappers.find((entry) => matchName(last, new Set(entry.names)) !== null);
|
|
206
|
+
if (spec === undefined)
|
|
207
|
+
return resolution;
|
|
208
|
+
table = [{ ...spec, names: [last] }, ...table];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Builds the listeners that enforce `compiled` for one file. Every command
|
|
213
|
+
* site in the file, including those inside command substitutions and
|
|
214
|
+
* functions, is checked once.
|
|
215
|
+
*/
|
|
216
|
+
function wrapperRuleListeners(compiled, context) {
|
|
217
|
+
const { sourceCode } = context;
|
|
218
|
+
return {
|
|
219
|
+
File() {
|
|
220
|
+
let functions;
|
|
221
|
+
for (const site of sourceCode.getCommandSites()) {
|
|
222
|
+
const resolution = resolveChain(compiled, site);
|
|
223
|
+
const finding = findListedCommand(compiled, resolution.chain);
|
|
224
|
+
if (finding === null || finding.wrapped)
|
|
225
|
+
continue;
|
|
226
|
+
// A function the file defines runs instead of the external command of
|
|
227
|
+
// the same name, wherever it is defined. Only the command word itself
|
|
228
|
+
// is looked up as a function; behind a wrapper (`command curl`) the
|
|
229
|
+
// external command runs.
|
|
230
|
+
if (finding.index === 0) {
|
|
231
|
+
functions ??= definedFunctionNames(sourceCode);
|
|
232
|
+
if (functions.has(finding.word))
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (isLocalInvocation(compiled, finding, resolution, site))
|
|
236
|
+
continue;
|
|
237
|
+
context.report({
|
|
238
|
+
node: site.node,
|
|
239
|
+
messageId: 'unwrappedCommand',
|
|
240
|
+
data: { command: finding.command, wrapper: compiled.wrapperLabel },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The first operand of `args`, once every option of `operand` has been read,
|
|
248
|
+
* or `null` when the invocation cannot be shown to use only that operand.
|
|
249
|
+
*
|
|
250
|
+
* The whole argument list up to a literal `--` is scanned, not just up to the
|
|
251
|
+
* operand, because commands such as `git clone` accept options after their
|
|
252
|
+
* operands (`git clone ./x --recurse-submodules`). Every option must be one
|
|
253
|
+
* the entry declares; an undeclared option anywhere before `--`, a dynamic word
|
|
254
|
+
* where an option or the first operand would stand, or a missing or dynamic
|
|
255
|
+
* operand gives `null`. Words after `--` are operands and are not read.
|
|
256
|
+
* `args` holds each word's static text, or `null` for a dynamic word. A dynamic
|
|
257
|
+
* word is tolerated as an option's value (`--depth "$n"`) and after the first
|
|
258
|
+
* operand (`git clone ./x "$dest"`), where it cannot change which operand
|
|
259
|
+
* comes first.
|
|
260
|
+
*/
|
|
261
|
+
function firstOperand(args, operand) {
|
|
262
|
+
const noArg = new Set(operand.noArgFlags ?? []);
|
|
263
|
+
const withArg = new Set(operand.argFlags ?? []);
|
|
264
|
+
let first;
|
|
265
|
+
for (let i = 0; i < args.length; i++) {
|
|
266
|
+
const arg = args[i];
|
|
267
|
+
if (arg === null) {
|
|
268
|
+
if (first === undefined)
|
|
269
|
+
return null;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (arg === '--') {
|
|
273
|
+
return first === undefined ? (args[i + 1] ?? null) : first;
|
|
274
|
+
}
|
|
275
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
276
|
+
first ??= arg;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (noArg.has(arg))
|
|
280
|
+
continue;
|
|
281
|
+
if (withArg.has(arg)) {
|
|
282
|
+
i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const equals = arg.indexOf('=');
|
|
286
|
+
if (arg.startsWith('--') && equals !== -1 && withArg.has(arg.slice(0, equals)))
|
|
287
|
+
continue;
|
|
288
|
+
// A short option with its value attached (`-j4`).
|
|
289
|
+
if (!arg.startsWith('--') && arg.length > 2 && withArg.has(arg.slice(0, 2)))
|
|
290
|
+
continue;
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return first ?? null;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Whether the invocation `finding` describes only acts locally, according to
|
|
297
|
+
* the {@link WrapperRuleLocalInvocation} bound to its command. Anything
|
|
298
|
+
* not provably local is not local.
|
|
299
|
+
*/
|
|
300
|
+
function isLocalInvocation(compiled, finding, resolution, site) {
|
|
301
|
+
const local = compiled.localInvocations.get(finding.command);
|
|
302
|
+
if (local === undefined)
|
|
303
|
+
return false;
|
|
304
|
+
const start = resolution.chainIndices[finding.lastIndex];
|
|
305
|
+
const args = site.argv.slice(start + 1).map((word) => (word.static ? word.text : null));
|
|
306
|
+
if (local.argLists?.some((list) => list.length === args.length && list.every((a, i) => a === args[i]))) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
if (local.localOperand === undefined)
|
|
310
|
+
return false;
|
|
311
|
+
const source = firstOperand(args, local.localOperand);
|
|
312
|
+
return (source !== null && local.localOperand.localPrefixes.some((prefix) => source.startsWith(prefix)));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Finds the first listed command in `chain` (outermost first) that counts,
|
|
316
|
+
* and whether the wrapper encloses it. Command and wrapper words are compared
|
|
317
|
+
* with {@link matchName}. A command restricted to subcommands counts only
|
|
318
|
+
* when the next chain word is exactly one of them. The scan stops at the
|
|
319
|
+
* first word that is not static: `resolveArgv0` never resolves past one.
|
|
320
|
+
*/
|
|
321
|
+
function findListedCommand(compiled, chain) {
|
|
322
|
+
for (const [index, word] of chain.entries()) {
|
|
323
|
+
const text = staticText(word);
|
|
324
|
+
if (text === null)
|
|
325
|
+
return null;
|
|
326
|
+
const name = matchName(text, compiled.commands);
|
|
327
|
+
if (name === null)
|
|
328
|
+
continue;
|
|
329
|
+
let command = name;
|
|
330
|
+
let lastIndex = index;
|
|
331
|
+
const allowed = compiled.subcommands.get(name);
|
|
332
|
+
if (allowed !== undefined) {
|
|
333
|
+
const subcommand = staticText(chain[index + 1]);
|
|
334
|
+
if (subcommand === null || !allowed.has(subcommand))
|
|
335
|
+
continue;
|
|
336
|
+
command = `${name} ${subcommand}`;
|
|
337
|
+
lastIndex = index + 1;
|
|
338
|
+
}
|
|
339
|
+
const wrapped = chain.slice(0, index).some((outer) => {
|
|
340
|
+
const outerName = staticText(outer);
|
|
341
|
+
return outerName !== null && matchName(outerName, compiled.wrapperNames) !== null;
|
|
342
|
+
});
|
|
343
|
+
return { command, wrapped, index, word: text, lastIndex };
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The wrapper-rule factory, typed for this package's rules: it is what
|
|
349
|
+
* {@link createWrapperRule} returns, with the shell-specific rule type and an
|
|
350
|
+
* options tuple chosen by the caller. A built-in rule whose config comes from
|
|
351
|
+
* its own options (`sh/require-network-wrapper`) builds the factory's rule
|
|
352
|
+
* per config and calls its `create` with its own context, so the options
|
|
353
|
+
* tuple is that rule's. The factory's rule itself reads no options.
|
|
354
|
+
*
|
|
355
|
+
* @internal
|
|
356
|
+
*/
|
|
357
|
+
export function buildWrapperRule(config) {
|
|
358
|
+
const compiled = compileWrapperRule(config);
|
|
359
|
+
return createRule({
|
|
360
|
+
name: WRAPPER_RULE_NAME,
|
|
361
|
+
docsUrl: compiled.docsUrl,
|
|
362
|
+
meta: {
|
|
363
|
+
type: 'problem',
|
|
364
|
+
docs: { description: compiled.description },
|
|
365
|
+
schema: [],
|
|
366
|
+
messages: WRAPPER_RULE_MESSAGES,
|
|
367
|
+
},
|
|
368
|
+
create: (context) => wrapperRuleListeners(compiled, context),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Creates a rule that reports every listed command that is not run through
|
|
373
|
+
* `config.wrapper`, for a rule pack's "must be wrapped by X" policies:
|
|
374
|
+
*
|
|
375
|
+
* ```js
|
|
376
|
+
* import { createWrapperRule } from '@eslint-sh/eslint-plugin';
|
|
377
|
+
*
|
|
378
|
+
* export const requireAudit = createWrapperRule({
|
|
379
|
+
* commands: ['kubectl', 'terraform'],
|
|
380
|
+
* wrapper: 'audit-run',
|
|
381
|
+
* });
|
|
382
|
+
* ```
|
|
383
|
+
*
|
|
384
|
+
* The rule declares `meta.languages: ["sh/sh"]`, takes no options (the
|
|
385
|
+
* config is bound here), and reports with message id `unwrappedCommand`,
|
|
386
|
+
* naming the command and the wrapper. Its `meta.docs.url` and
|
|
387
|
+
* `meta.docs.description` default to this package's wrapper-rules page and a
|
|
388
|
+
* sentence naming the policy; `config.docs` overrides either. A listed
|
|
389
|
+
* command is wrapped when the wrapper encloses it in `sh-ast/analyze`'s
|
|
390
|
+
* transparent-wrapper chain, so `audit-run sudo kubectl apply` is wrapped.
|
|
391
|
+
* Command and wrapper words written as paths match by basename
|
|
392
|
+
* (`/usr/bin/kubectl`). Commands inside `$(…)`, functions, and pipelines are
|
|
393
|
+
* checked; a command whose name or subcommand is not static is not reported.
|
|
394
|
+
*
|
|
395
|
+
* @param config - The commands, the wrapper they must be run through, any
|
|
396
|
+
* subcommand restrictions, and optional `meta.docs` overrides.
|
|
397
|
+
* @returns A {@link ShellRule}: an ESLint rule for the `sh/sh` language,
|
|
398
|
+
* typed so a pack's own declarations need no `@eslint/core` import to name it.
|
|
399
|
+
* @throws TypeError when `config` is malformed (no `commands` array, an empty
|
|
400
|
+
* wrapper name, a subcommand restriction without `names`, or an empty or
|
|
401
|
+
* non-string `docs.url` / `docs.description`).
|
|
402
|
+
*
|
|
403
|
+
* @public
|
|
404
|
+
*/
|
|
405
|
+
export function createWrapperRule(config) {
|
|
406
|
+
return buildWrapperRule(config);
|
|
407
|
+
}
|
|
@@ -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>;
|