@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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sh/no-undeclared-commands`: reports external commands a script uses but
|
|
3
|
+
* does not declare.
|
|
4
|
+
*
|
|
5
|
+
* A script silently depends on every external command it runs, and any of
|
|
6
|
+
* them may be missing on the machine it lands on. When a project states a
|
|
7
|
+
* script's dependencies up front, this rule keeps that statement complete.
|
|
8
|
+
*
|
|
9
|
+
* The rule splits the question in two:
|
|
10
|
+
*
|
|
11
|
+
* - **Usage** is a fact about the syntax tree, computed by the internal
|
|
12
|
+
* command harvest (`../command-harvest/harvest-commands.ts`): every static
|
|
13
|
+
* command name, minus the dialect's builtins and the file's own functions,
|
|
14
|
+
* following transparent wrappers.
|
|
15
|
+
* - **Declaration** is project policy, answered by the consumer's
|
|
16
|
+
* `DeclarationConvention`, bound in `settings.sh.declarationConvention`
|
|
17
|
+
* and resolved with `resolveDeclarationConvention`. With no convention
|
|
18
|
+
* bound the rule is inert: shipping a default would impose one project's
|
|
19
|
+
* declaration syntax on every project.
|
|
20
|
+
*
|
|
21
|
+
* The convention is bound through settings only. The rule has no
|
|
22
|
+
* `declarationConvention` option, because ESLint copies rule options with
|
|
23
|
+
* `structuredClone`, which cannot carry a convention's methods.
|
|
24
|
+
*
|
|
25
|
+
* Each undeclared command reports once, at the word naming it in its first
|
|
26
|
+
* use. The rule offers no fix: adding a dependency is a decision for the
|
|
27
|
+
* author. The report instead says how to declare the command, in the
|
|
28
|
+
* convention's own words when it has a `declarationHint` hook, since only the
|
|
29
|
+
* convention knows its syntax.
|
|
30
|
+
*/
|
|
31
|
+
import { harvestCommands } from '../command-harvest/harvest-commands.js';
|
|
32
|
+
import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
|
|
33
|
+
import { createRule } from './create-rule.js';
|
|
34
|
+
import { readDeclaredCommands, toLintedFile, } from '../declaration-conventions/read-declared-commands.js';
|
|
35
|
+
import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
|
|
36
|
+
import { networkWrapperSpec } from './resolve-network-wrapper.js';
|
|
37
|
+
/** The registry name of this rule (`sh/no-undeclared-commands` in a user's config). */
|
|
38
|
+
const RULE_NAME = 'no-undeclared-commands';
|
|
39
|
+
/** The report's second sentence when the convention offers no hint for a command. */
|
|
40
|
+
const GENERIC_HINT = "Declare it with the project's declaration convention.";
|
|
41
|
+
/**
|
|
42
|
+
* The convention's hint for `command`, or {@link GENERIC_HINT} when it has
|
|
43
|
+
* none. Throws when the hook returns something other than `null` or a
|
|
44
|
+
* non-empty single-line string, so a broken hook surfaces as a readable
|
|
45
|
+
* configuration error instead of a garbled report.
|
|
46
|
+
*/
|
|
47
|
+
function howToDeclare(convention, command, sourceCode, file) {
|
|
48
|
+
if (convention.declarationHint === undefined)
|
|
49
|
+
return GENERIC_HINT;
|
|
50
|
+
const hint = convention.declarationHint(command, sourceCode, file);
|
|
51
|
+
if (hint === null)
|
|
52
|
+
return GENERIC_HINT;
|
|
53
|
+
if (typeof hint !== 'string' || hint.trim() === '' || /[\r\n\u2028\u2029]/u.test(hint)) {
|
|
54
|
+
throw new Error(`Invalid settings.sh.declarationConvention: \`declarationHint\` must return null or a non-empty single-line string, but returned ${typeof hint === 'string' ? JSON.stringify(hint) : typeof hint} for ${JSON.stringify(command)}.`);
|
|
55
|
+
}
|
|
56
|
+
return hint;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The `sh/no-undeclared-commands` rule module.
|
|
60
|
+
*
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export const noUndeclaredCommands = createRule({
|
|
64
|
+
name: RULE_NAME,
|
|
65
|
+
meta: {
|
|
66
|
+
type: 'problem',
|
|
67
|
+
docs: {
|
|
68
|
+
description: 'Require every external command a script uses to be declared by the configured declaration convention',
|
|
69
|
+
},
|
|
70
|
+
schema: [
|
|
71
|
+
{
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
transparentWrappers: {
|
|
75
|
+
type: 'array',
|
|
76
|
+
items: { type: 'string', minLength: 1 },
|
|
77
|
+
uniqueItems: true,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
messages: {
|
|
84
|
+
undeclaredCommand: "'{{command}}' is used but not declared. {{howToDeclare}}",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
create(context) {
|
|
88
|
+
const convention = resolveDeclarationConvention(context);
|
|
89
|
+
if (convention === null) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
const { sourceCode } = context;
|
|
93
|
+
const transparentWrappers = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
|
|
94
|
+
return {
|
|
95
|
+
File() {
|
|
96
|
+
// Only names matter here, so every kind of declaration counts the same.
|
|
97
|
+
const declared = new Set(readDeclaredCommands(convention, context).map(({ name }) => name));
|
|
98
|
+
for (const { name, node } of harvestCommands(sourceCode, {
|
|
99
|
+
transparentWrappers,
|
|
100
|
+
}).values()) {
|
|
101
|
+
if (declared.has(name))
|
|
102
|
+
continue;
|
|
103
|
+
context.report({
|
|
104
|
+
node,
|
|
105
|
+
messageId: 'undeclaredCommand',
|
|
106
|
+
data: {
|
|
107
|
+
command: name,
|
|
108
|
+
howToDeclare: howToDeclare(convention, name, sourceCode, toLintedFile(context)),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule's single options object. It mirrors `no-undeclared-commands`'
|
|
3
|
+
* options, so a project configures both rules with the same wrappers and
|
|
4
|
+
* they compute the same usage.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export interface NoUnusedDeclarationsOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Names of project-specific transparent wrappers (`with-retry`), added to
|
|
11
|
+
* the default wrapper table (see `harvestWrapperTable`). The word after a
|
|
12
|
+
* listed wrapper is the command it runs, so `with-retry curl` uses `curl`
|
|
13
|
+
* as well as `with-retry`, and `with-retry "$tool"` runs a command the
|
|
14
|
+
* file cannot name.
|
|
15
|
+
*/
|
|
16
|
+
readonly transparentWrappers?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The `sh/no-unused-declarations` rule module.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export declare const noUnusedDeclarations: import("./define-shell-rule.js").ShellRule<[NoUnusedDeclarationsOptions?], "unusedDeclaration" | "unusedFileDeclaration" | "unusedDescribedDeclaration">;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
|
|
2
|
+
import { harvestCommands } from '../command-harvest/harvest-commands.js';
|
|
3
|
+
import { harvestWrapperTable } from '../command-harvest/wrapper-table.js';
|
|
4
|
+
import { createRule } from './create-rule.js';
|
|
5
|
+
import { readDeclaredCommands } from '../declaration-conventions/read-declared-commands.js';
|
|
6
|
+
import { resolveDeclarationConvention } from './resolve-declaration-convention.js';
|
|
7
|
+
import { networkWrapperSpec } from './resolve-network-wrapper.js';
|
|
8
|
+
import { SHELL_WRAPPER } from './transparent-wrappers.js';
|
|
9
|
+
/** The registry name of this rule (`sh/no-unused-declarations` in a user's config). */
|
|
10
|
+
const RULE_NAME = 'no-unused-declarations';
|
|
11
|
+
/**
|
|
12
|
+
* A shell name (`cleanup`, `_on_exit`): the form of a `trap` action that
|
|
13
|
+
* names a single function or command rather than a code string.
|
|
14
|
+
*
|
|
15
|
+
* @see https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_235 — Name
|
|
16
|
+
*/
|
|
17
|
+
const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
18
|
+
/** A `trap` option cluster that lists or prints traps instead of setting one (`-l`, `-p`, `-P`). */
|
|
19
|
+
const TRAP_PRINT_OPTIONS = /^-[lpP]+$/u;
|
|
20
|
+
/** A decimal integer: as `trap`'s first operand, it makes every operand a condition to reset. */
|
|
21
|
+
const UNSIGNED_INTEGER = /^[0-9]+$/u;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `trap`'s `operands` (the words after `trap`) set an action that is a
|
|
24
|
+
* code string, which the shell evaluates later and this rule cannot read.
|
|
25
|
+
*
|
|
26
|
+
* Not a code string: no action at all (`trap`, `trap -p`, `trap -l`); the
|
|
27
|
+
* reset action `-` and the ignore action `''`; a leading integer, which POSIX
|
|
28
|
+
* treats as a condition to reset; and a plain name (`trap cleanup EXIT`),
|
|
29
|
+
* which runs that function or command. A name used as an action is an
|
|
30
|
+
* argument word, so it already counts as used. An action that is not static
|
|
31
|
+
* is a code string the rule cannot know.
|
|
32
|
+
*
|
|
33
|
+
* @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#trap
|
|
34
|
+
* @see https://www.gnu.org/software/bash/manual/bash.html#index-trap
|
|
35
|
+
*/
|
|
36
|
+
function trapSetsCodeString(operands) {
|
|
37
|
+
let index = 0;
|
|
38
|
+
if (operands.length > 0 && operands[0].static) {
|
|
39
|
+
const { text } = operands[0];
|
|
40
|
+
if (TRAP_PRINT_OPTIONS.test(text))
|
|
41
|
+
return false;
|
|
42
|
+
if (text === '--')
|
|
43
|
+
index = 1;
|
|
44
|
+
}
|
|
45
|
+
if (index >= operands.length)
|
|
46
|
+
return false;
|
|
47
|
+
const action = operands[index];
|
|
48
|
+
if (!action.static)
|
|
49
|
+
return true;
|
|
50
|
+
const { text } = action;
|
|
51
|
+
return !(text === '' || text === '-' || UNSIGNED_INTEGER.test(text) || SHELL_NAME.test(text));
|
|
52
|
+
}
|
|
53
|
+
/** Shell names, from {@link SHELL_WRAPPER}: a shell with no script operand reads code from stdin. */
|
|
54
|
+
const SHELL_NAMES = new Set(SHELL_WRAPPER.names);
|
|
55
|
+
/** The builtins that run another file's code in this shell: POSIX `.` and Bash's `source`. */
|
|
56
|
+
const SOURCE_COMMANDS = new Set(['.', 'source']);
|
|
57
|
+
/**
|
|
58
|
+
* The wrappers whose path forms (`/bin/bash`, `/usr/bin/env`) the completeness
|
|
59
|
+
* check also follows, by basename: shells invoked as commands and `env`,
|
|
60
|
+
* the wrappers that can run an embedded command string. sh-ast matches
|
|
61
|
+
* wrapper names exactly, so without this `/bin/bash -c '…'` would resolve to
|
|
62
|
+
* the static command `/bin/bash`.
|
|
63
|
+
*/
|
|
64
|
+
const PATH_FOLLOWED_WRAPPERS = [
|
|
65
|
+
SHELL_WRAPPER,
|
|
66
|
+
...DEFAULT_TRANSPARENT_WRAPPERS.filter((spec) => spec.names.includes('env')),
|
|
67
|
+
];
|
|
68
|
+
/** The basename of `text` when it is a path (contains a slash), else `null`. */
|
|
69
|
+
function pathBasename(text) {
|
|
70
|
+
const slash = text.lastIndexOf('/');
|
|
71
|
+
return slash === -1 ? null : text.slice(slash + 1);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The effective command of `site` under `table`, also following a shell or
|
|
75
|
+
* `env` named by path: while the effective command is a path whose basename
|
|
76
|
+
* names one of {@link PATH_FOLLOWED_WRAPPERS}, that path is added as a
|
|
77
|
+
* wrapper with the same spec and the site is resolved again. This mirrors how
|
|
78
|
+
* `createWrapperRule` follows path-named wrappers. Each round adds a new
|
|
79
|
+
* path, and a site has finitely many words, so the loop ends.
|
|
80
|
+
*/
|
|
81
|
+
function resolveEffective(site, table) {
|
|
82
|
+
let current = table;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const resolution = site.resolveInvokedCommand({ transparentWrappers: current });
|
|
85
|
+
const { effective } = resolution;
|
|
86
|
+
if (!effective.static)
|
|
87
|
+
return resolution;
|
|
88
|
+
const path = effective.text;
|
|
89
|
+
const base = pathBasename(path);
|
|
90
|
+
if (base === null || current.some((spec) => spec.names.includes(path)))
|
|
91
|
+
return resolution;
|
|
92
|
+
const spec = PATH_FOLLOWED_WRAPPERS.find((entry) => entry.names.includes(base));
|
|
93
|
+
if (spec === undefined)
|
|
94
|
+
return resolution;
|
|
95
|
+
current = [{ ...spec, names: [path] }, ...current];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** A short-option cluster that includes `c` (`-c`, `-ec`, `-xc`): a shell's command-string flag. */
|
|
99
|
+
const COMMAND_STRING_CLUSTER = /^-[A-Za-z]*c[A-Za-z]*$/u;
|
|
100
|
+
/**
|
|
101
|
+
* Whether `argv` names a shell as an argument and later passes it a command
|
|
102
|
+
* string: some word after argv0 is statically a shell name, by name or by
|
|
103
|
+
* basename (`sh`, `/bin/bash`), and a later word is a short-option cluster
|
|
104
|
+
* containing `c`. Many commands run their arguments as a command line
|
|
105
|
+
* (`xargs sh -c '…'`, `find . -exec sh -c '…' \;`, `setsid -f bash -c …`,
|
|
106
|
+
* `busybox sh -c …`), and they are not wrappers this rule follows, so the
|
|
107
|
+
* shell never becomes the effective command. Matching the words directly
|
|
108
|
+
* catches them without modeling each command's options.
|
|
109
|
+
*/
|
|
110
|
+
function passesShellCommandString(argv) {
|
|
111
|
+
const shellIndex = argv.findIndex((word, index) => index > 0 && word.static && SHELL_NAMES.has(pathBasename(word.text) ?? word.text));
|
|
112
|
+
return (shellIndex !== -1 &&
|
|
113
|
+
argv.slice(shellIndex + 1).some((word) => word.static && COMMAND_STRING_CLUSTER.test(word.text)));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether `site` may run code that cannot be read from the source:
|
|
117
|
+
*
|
|
118
|
+
* - its effective command is not static, including an embedded command
|
|
119
|
+
* string;
|
|
120
|
+
* - it is `eval`;
|
|
121
|
+
* - it is a `trap` that sets a code string;
|
|
122
|
+
* - it is `.` or `source` with an operand, which runs another file;
|
|
123
|
+
* - it is a shell, by name or path, that is the effective command itself.
|
|
124
|
+
* With {@link SHELL_WRAPPER} followed, a script operand would be the
|
|
125
|
+
* effective command and `-c` would be an embedded command, so a shell that
|
|
126
|
+
* is still the effective command reads its code from standard input
|
|
127
|
+
* (`curl x | bash`, `bash < script`, `bash -s`);
|
|
128
|
+
* - it hands a command string to a shell named in its arguments (see
|
|
129
|
+
* {@link passesShellCommandString}).
|
|
130
|
+
*
|
|
131
|
+
* `completenessWrappers` is the table to follow (see the rule's `create`).
|
|
132
|
+
*/
|
|
133
|
+
function runsUnknownCode(site, completenessWrappers) {
|
|
134
|
+
if (passesShellCommandString(site.argv))
|
|
135
|
+
return true;
|
|
136
|
+
const resolution = resolveEffective(site, completenessWrappers);
|
|
137
|
+
const { effective, chainIndices } = resolution;
|
|
138
|
+
if (!effective.static)
|
|
139
|
+
return true;
|
|
140
|
+
const { text } = effective;
|
|
141
|
+
if (text === 'eval')
|
|
142
|
+
return true;
|
|
143
|
+
if (SHELL_NAMES.has(pathBasename(text) ?? text))
|
|
144
|
+
return true;
|
|
145
|
+
if (!SOURCE_COMMANDS.has(text) && text !== 'trap')
|
|
146
|
+
return false;
|
|
147
|
+
// `chainIndices` locates the effective command in argv; its operands follow it.
|
|
148
|
+
const effectiveIndex = chainIndices.at(-1);
|
|
149
|
+
const operands = effectiveIndex === undefined ? [] : site.argv.slice(effectiveIndex + 1);
|
|
150
|
+
return text === 'trap' ? trapSetsCodeString(operands) : operands.length > 0;
|
|
151
|
+
}
|
|
152
|
+
/** `text`, and also its basename when `text` is a path (contains a slash). */
|
|
153
|
+
function namesFor(text) {
|
|
154
|
+
const base = pathBasename(text);
|
|
155
|
+
return base === null ? [text] : [text, base];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* How a report names where an out-of-script declaration lives: `path:line`
|
|
159
|
+
* (or `path` when the line is unknown) for a file, the description for a
|
|
160
|
+
* described source.
|
|
161
|
+
*/
|
|
162
|
+
function sourceText(declared) {
|
|
163
|
+
if (declared.kind === 'described')
|
|
164
|
+
return declared.description;
|
|
165
|
+
return declared.line === undefined ? declared.path : `${declared.path}:${String(declared.line)}`;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The `sh/no-unused-declarations` rule module.
|
|
169
|
+
*
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
export const noUnusedDeclarations = createRule({
|
|
173
|
+
name: RULE_NAME,
|
|
174
|
+
meta: {
|
|
175
|
+
type: 'suggestion',
|
|
176
|
+
docs: {
|
|
177
|
+
description: 'Disallow declaring external commands that a script never uses, per the configured declaration convention',
|
|
178
|
+
},
|
|
179
|
+
fixable: 'code',
|
|
180
|
+
schema: [
|
|
181
|
+
{
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
transparentWrappers: {
|
|
185
|
+
type: 'array',
|
|
186
|
+
items: { type: 'string', minLength: 1 },
|
|
187
|
+
uniqueItems: true,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
messages: {
|
|
194
|
+
unusedDeclaration: "'{{command}}' is declared but never used. Remove the declaration so the script's stated dependencies match what it runs.",
|
|
195
|
+
unusedFileDeclaration: "'{{command}}' is declared in {{source}} but this script never uses it. Remove it there so the script's stated dependencies match what it runs.",
|
|
196
|
+
unusedDescribedDeclaration: "'{{command}}' is declared in {{source}} but this script never uses it. Remove it there so the script's stated dependencies match what it runs.",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
create(context) {
|
|
200
|
+
const convention = resolveDeclarationConvention(context);
|
|
201
|
+
if (convention === null) {
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
const { sourceCode } = context;
|
|
205
|
+
const transparentWrappers = harvestWrapperTable(sourceCode.dialect, context.options[0]?.transparentWrappers ?? [], networkWrapperSpec(context.settings));
|
|
206
|
+
// The completeness check follows the harvest's table plus shells
|
|
207
|
+
// invoked as commands, so `bash -c '…'` and `sh -c '…'` resolve to an
|
|
208
|
+
// embedded command (as in `no-dynamic-invocation`) instead of stopping
|
|
209
|
+
// at the shell. `env -S` is already embedded in the defaults. Path
|
|
210
|
+
// forms (`/bin/bash`, `/usr/bin/env`) are followed per site by
|
|
211
|
+
// `resolveEffective`.
|
|
212
|
+
const completenessWrappers = [...transparentWrappers, SHELL_WRAPPER];
|
|
213
|
+
// Bound once so the hook keeps its convention as `this`, however it is written.
|
|
214
|
+
const removeDeclaration = convention.removeDeclaration?.bind(convention);
|
|
215
|
+
return {
|
|
216
|
+
File() {
|
|
217
|
+
const sites = sourceCode.getCommandSites();
|
|
218
|
+
if (sites.some((site) => runsUnknownCode(site, completenessWrappers)))
|
|
219
|
+
return;
|
|
220
|
+
const used = new Set();
|
|
221
|
+
for (const name of harvestCommands(sourceCode, { transparentWrappers }).keys()) {
|
|
222
|
+
for (const each of namesFor(name))
|
|
223
|
+
used.add(each);
|
|
224
|
+
}
|
|
225
|
+
for (const site of sites) {
|
|
226
|
+
for (const word of site.argv.slice(1)) {
|
|
227
|
+
if (word.static)
|
|
228
|
+
for (const each of namesFor(word.text))
|
|
229
|
+
used.add(each);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const declared of readDeclaredCommands(convention, context)) {
|
|
233
|
+
if (used.has(declared.name))
|
|
234
|
+
continue;
|
|
235
|
+
if (declared.kind === 'script') {
|
|
236
|
+
context.report({
|
|
237
|
+
loc: {
|
|
238
|
+
start: sourceCode.getLocFromIndex(declared.range[0]),
|
|
239
|
+
end: sourceCode.getLocFromIndex(declared.range[1]),
|
|
240
|
+
},
|
|
241
|
+
messageId: 'unusedDeclaration',
|
|
242
|
+
data: { command: declared.name },
|
|
243
|
+
fix: removeDeclaration
|
|
244
|
+
? (fixer) => removeDeclaration(sourceCode, declared, fixer)
|
|
245
|
+
: null,
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
context.report({
|
|
250
|
+
// A start with no end: the file as a whole, not any text in it.
|
|
251
|
+
loc: sourceCode.getLocFromIndex(0),
|
|
252
|
+
messageId: declared.kind === 'file' ? 'unusedFileDeclaration' : 'unusedDescribedDeclaration',
|
|
253
|
+
data: { command: declared.name, source: sourceText(declared) },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule's single options object.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export interface PreferLocalOptions {
|
|
7
|
+
/** Variable names that may be assigned in a function without `local`. */
|
|
8
|
+
readonly allow?: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* A regular expression (source text, matched with the `u` flag); a name that
|
|
11
|
+
* matches it may be assigned without `local`. Defaults to UPPER_CASE names.
|
|
12
|
+
*/
|
|
13
|
+
readonly allowPattern?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Built-in rule `sh/prefer-local`. @internal */
|
|
16
|
+
export declare const preferLocal: import("./define-shell-rule.js").ShellRule<[PreferLocalOptions], "preferLocal">;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { childNodes, walkShell } from '../language/syntax-view.js';
|
|
2
|
+
import { createRule } from './create-rule.js';
|
|
3
|
+
import { compileUserPattern } from './user-pattern.js';
|
|
4
|
+
import { lineBreakOf } from '../language/line-breaks.js';
|
|
5
|
+
/** The `allowPattern` used when none is configured: UPPER_CASE globals. */
|
|
6
|
+
const DEFAULT_ALLOW_PATTERN = '^[A-Z][A-Z0-9_]*$';
|
|
7
|
+
/** A valid shell variable name; anything else is not a binding target. */
|
|
8
|
+
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
9
|
+
/** Declaration builtins whose names are function-scoped unless `-g` is given. */
|
|
10
|
+
const SCOPED_DECLARATIONS = new Set(['local', 'declare', 'typeset']);
|
|
11
|
+
/** `read` options that consume an argument that is not a variable name. */
|
|
12
|
+
const READ_OPTIONS_WITH_ARGUMENT = new Set([
|
|
13
|
+
'p',
|
|
14
|
+
'd',
|
|
15
|
+
'n',
|
|
16
|
+
'N',
|
|
17
|
+
't',
|
|
18
|
+
'u',
|
|
19
|
+
'i',
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The variables a `read` command assigns, skipping its options. Option
|
|
23
|
+
* clusters follow `read`'s own parsing: a letter that takes an argument uses
|
|
24
|
+
* the rest of the cluster, or else the next word. `-a`'s array name is an
|
|
25
|
+
* ordinary target word. Words that are not static are not guessed at.
|
|
26
|
+
*/
|
|
27
|
+
function readTargets(args, sourceCode) {
|
|
28
|
+
const targets = [];
|
|
29
|
+
let optionsEnded = false;
|
|
30
|
+
let nextIsOptionArgument = false;
|
|
31
|
+
for (const word of args.slice(1)) {
|
|
32
|
+
const text = sourceCode.resolveWordText(word);
|
|
33
|
+
if (nextIsOptionArgument) {
|
|
34
|
+
nextIsOptionArgument = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (text === null)
|
|
38
|
+
continue;
|
|
39
|
+
if (!optionsEnded && text === '--') {
|
|
40
|
+
optionsEnded = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!optionsEnded && text.startsWith('-') && text.length > 1) {
|
|
44
|
+
for (let index = 1; index < text.length; index += 1) {
|
|
45
|
+
const letter = text.charAt(index);
|
|
46
|
+
if (READ_OPTIONS_WITH_ARGUMENT.has(letter)) {
|
|
47
|
+
nextIsOptionArgument = index === text.length - 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (VARIABLE_NAME.test(text))
|
|
54
|
+
targets.push({ name: text, kind: 'assign', node: word });
|
|
55
|
+
}
|
|
56
|
+
return targets;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The variables `node` assigns or declares, itself only (not its children).
|
|
60
|
+
* `declare`/`typeset` with a `-g` flag, and every non-scoped declaration
|
|
61
|
+
* (`export`, `readonly`), bind their names as intentional globals.
|
|
62
|
+
*/
|
|
63
|
+
function bindingsOf(node, sourceCode) {
|
|
64
|
+
if (node.type === 'ForClause') {
|
|
65
|
+
const { loop } = node;
|
|
66
|
+
if (loop?.type !== 'WordIter' || loop.name === undefined)
|
|
67
|
+
return [];
|
|
68
|
+
return [{ name: loop.name.value, kind: 'assign', node: loop.name }];
|
|
69
|
+
}
|
|
70
|
+
if (node.type === 'CallExpr') {
|
|
71
|
+
if (node.args.length === 0) {
|
|
72
|
+
return node.assigns.flatMap((assign) => assign.name === undefined
|
|
73
|
+
? []
|
|
74
|
+
: [{ name: assign.name.value, kind: 'assign', node: assign }]);
|
|
75
|
+
}
|
|
76
|
+
return sourceCode.getCommandName(node) === 'read' ? readTargets(node.args, sourceCode) : [];
|
|
77
|
+
}
|
|
78
|
+
if (node.type === 'DeclClause') {
|
|
79
|
+
const variant = node.variant?.value;
|
|
80
|
+
const flags = node.args
|
|
81
|
+
.filter((arg) => arg.naked && arg.name === undefined && arg.value !== undefined)
|
|
82
|
+
.map((arg) => (arg.value === undefined ? null : sourceCode.resolveWordText(arg.value)));
|
|
83
|
+
const isGlobal = variant === undefined ||
|
|
84
|
+
!SCOPED_DECLARATIONS.has(variant) ||
|
|
85
|
+
flags.some((flag) => flag !== null && /^-[A-Za-z]*g/u.test(flag));
|
|
86
|
+
return node.args.flatMap((arg) => arg.name === undefined
|
|
87
|
+
? []
|
|
88
|
+
: [
|
|
89
|
+
{
|
|
90
|
+
name: arg.name.value,
|
|
91
|
+
kind: isGlobal ? 'global' : 'local',
|
|
92
|
+
node: arg,
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
/** Node types whose contents run in a child process and cannot leak assignments. */
|
|
99
|
+
const ISOLATED_SCOPES = new Set(['Subshell', 'CmdSubst', 'ProcSubst']);
|
|
100
|
+
/** `BinaryCmd.op` values for `|` and `|&`, the pipeline operators. */
|
|
101
|
+
const PIPE_OPS = new Set([13, 14]);
|
|
102
|
+
/**
|
|
103
|
+
* Whether the file enables `shopt -s lastpipe`. With it, the last element of a
|
|
104
|
+
* pipeline runs in the current shell, so what that element assigns does leak.
|
|
105
|
+
* The option is read file-wide, wherever the `shopt` appears, because it
|
|
106
|
+
* applies to every pipeline that runs after it.
|
|
107
|
+
*/
|
|
108
|
+
function enablesLastpipe(root, sourceCode) {
|
|
109
|
+
let enabled = false;
|
|
110
|
+
walkShell(root, (node) => {
|
|
111
|
+
if (node.type !== 'CallExpr' || sourceCode.getCommandName(node) !== 'shopt')
|
|
112
|
+
return;
|
|
113
|
+
const words = node.args.slice(1).map((word) => sourceCode.resolveWordText(word));
|
|
114
|
+
if (words.includes('-s') && words.includes('lastpipe'))
|
|
115
|
+
enabled = true;
|
|
116
|
+
});
|
|
117
|
+
return enabled;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Every binding in `root`'s own scope, in source order. Nested function
|
|
121
|
+
* definitions and isolated scopes (subshells, substitutions) are not entered.
|
|
122
|
+
* Pipeline elements run in subshells, so only the last element of a pipeline
|
|
123
|
+
* is entered, and only when `lastpipe` is enabled.
|
|
124
|
+
*/
|
|
125
|
+
function scopeBindings(root, sourceCode, lastpipe) {
|
|
126
|
+
const found = [];
|
|
127
|
+
const visit = (node) => {
|
|
128
|
+
if (node.type === 'FuncDecl' || ISOLATED_SCOPES.has(node.type))
|
|
129
|
+
return;
|
|
130
|
+
if (node.type === 'BinaryCmd' && PIPE_OPS.has(node.op)) {
|
|
131
|
+
if (lastpipe && node.y !== undefined)
|
|
132
|
+
visit(node.y);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
found.push(...bindingsOf(node, sourceCode));
|
|
136
|
+
for (const child of childNodes(node))
|
|
137
|
+
visit(child);
|
|
138
|
+
};
|
|
139
|
+
visit(root);
|
|
140
|
+
return found.sort((a, b) => a.node.range[0] - b.node.range[0]);
|
|
141
|
+
}
|
|
142
|
+
/** Built-in rule `sh/prefer-local`. @internal */
|
|
143
|
+
export const preferLocal = createRule({
|
|
144
|
+
name: 'prefer-local',
|
|
145
|
+
meta: {
|
|
146
|
+
type: 'suggestion',
|
|
147
|
+
docs: {
|
|
148
|
+
description: 'require variables assigned inside a function to be declared `local`, unless intentionally global',
|
|
149
|
+
},
|
|
150
|
+
fixable: 'code',
|
|
151
|
+
schema: [
|
|
152
|
+
{
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
156
|
+
allowPattern: { type: 'string' },
|
|
157
|
+
},
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
defaultOptions: [{ allow: [], allowPattern: DEFAULT_ALLOW_PATTERN }],
|
|
162
|
+
messages: {
|
|
163
|
+
preferLocal: '`{{name}}` is assigned in function `{{function}}` without being declared `local`, so it leaks into the global scope. Declare it with `local {{name}}`.',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
create(context) {
|
|
167
|
+
const { sourceCode } = context;
|
|
168
|
+
// `local` is not POSIX: under `posix` there is nothing to declare with.
|
|
169
|
+
if (sourceCode.dialect === 'posix')
|
|
170
|
+
return {};
|
|
171
|
+
const [options] = context.options;
|
|
172
|
+
const allowed = new Set(options.allow ?? []);
|
|
173
|
+
const allowPattern = compileUserPattern('sh/prefer-local', 'allowPattern', options.allowPattern ?? DEFAULT_ALLOW_PATTERN, 'u');
|
|
174
|
+
const text = sourceCode.text;
|
|
175
|
+
const lastpipe = enablesLastpipe(sourceCode.ast, sourceCode);
|
|
176
|
+
// Names assigned or declared outside every function, with where each
|
|
177
|
+
// binding ends: assigned before a function is defined, they are globals
|
|
178
|
+
// the function is meant to share.
|
|
179
|
+
const topLevelBindings = [];
|
|
180
|
+
let topLevelCollected = false;
|
|
181
|
+
const topLevelBefore = (position) => {
|
|
182
|
+
if (!topLevelCollected) {
|
|
183
|
+
topLevelBindings.push(...scopeBindings(sourceCode.ast, sourceCode, lastpipe));
|
|
184
|
+
topLevelCollected = true;
|
|
185
|
+
}
|
|
186
|
+
return new Set(topLevelBindings.filter((b) => b.node.range[1] <= position).map((b) => b.name));
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* The one `local` declaration that covers `names`, as `[index, text]`, or
|
|
190
|
+
* `null` when the function has no `{ … }` block with a first statement to
|
|
191
|
+
* insert before. Every report of a function carries this same insertion
|
|
192
|
+
* (with the function's whole name list), because fixes that insert at one
|
|
193
|
+
* offset conflict: ESLint applies one per pass and stops after 10, so a
|
|
194
|
+
* function with more than 10 names would otherwise never settle.
|
|
195
|
+
*/
|
|
196
|
+
const insertionFor = (func, names) => {
|
|
197
|
+
const block = func.body?.cmd;
|
|
198
|
+
if (block?.type !== 'Block')
|
|
199
|
+
return null;
|
|
200
|
+
const first = block.stmts.at(0);
|
|
201
|
+
if (first === undefined)
|
|
202
|
+
return null;
|
|
203
|
+
const lineStart = Math.max(text.lastIndexOf('\n', first.range[0] - 1), text.lastIndexOf('\r', first.range[0] - 1));
|
|
204
|
+
const indent = text.slice(lineStart + 1, first.range[0]);
|
|
205
|
+
const separator = /^[ \t]*$/u.test(indent) ? lineBreakOf(text) + indent : '; ';
|
|
206
|
+
return [first.range[0], `local ${names.join(' ')}${separator}`];
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
FuncDecl(func) {
|
|
210
|
+
const { body } = func;
|
|
211
|
+
if (body === undefined || body.cmd?.type === 'Subshell')
|
|
212
|
+
return;
|
|
213
|
+
const global = topLevelBefore(func.range[0]);
|
|
214
|
+
const declared = new Set();
|
|
215
|
+
const functionName = func.name?.value ?? '';
|
|
216
|
+
const leaks = [];
|
|
217
|
+
for (const binding of scopeBindings(body, sourceCode, lastpipe)) {
|
|
218
|
+
const { name } = binding;
|
|
219
|
+
if (binding.kind !== 'assign') {
|
|
220
|
+
declared.add(name);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (declared.has(name) ||
|
|
224
|
+
global.has(name) ||
|
|
225
|
+
allowed.has(name) ||
|
|
226
|
+
allowPattern.test(name)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
declared.add(name);
|
|
230
|
+
leaks.push(binding);
|
|
231
|
+
}
|
|
232
|
+
if (leaks.length === 0)
|
|
233
|
+
return;
|
|
234
|
+
const insertion = insertionFor(func, leaks.map((binding) => binding.name));
|
|
235
|
+
for (const binding of leaks) {
|
|
236
|
+
const { name } = binding;
|
|
237
|
+
context.report({
|
|
238
|
+
node: binding.node,
|
|
239
|
+
messageId: 'preferLocal',
|
|
240
|
+
data: { name, function: functionName },
|
|
241
|
+
fix: insertion === null
|
|
242
|
+
? null
|
|
243
|
+
: (fixer) => fixer.insertTextBeforeRange([insertion[0], insertion[0]], insertion[1]),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message ids this rule reports with: `unquotedExpansion` for the problem,
|
|
3
|
+
* `wrapInQuotes` for its suggestion.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export type QuoteExpansionsMessageId = 'unquotedExpansion' | 'wrapInQuotes';
|
|
8
|
+
/**
|
|
9
|
+
* The `sh/quote-expansions` rule module.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare const quoteExpansions: import("./define-shell-rule.js").ShellRule<[], QuoteExpansionsMessageId>;
|