@eslint/shell 0.0.0-stage → 0.1.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.
Files changed (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +157 -2
  3. package/dist/index.d.ts +79 -0
  4. package/dist/index.js +74 -0
  5. package/dist/languages/shell-language.d.ts +36 -0
  6. package/dist/languages/shell-language.js +72 -0
  7. package/dist/languages/shell-source-code.d.ts +55 -0
  8. package/dist/languages/shell-source-code.js +184 -0
  9. package/dist/parser/parse.d.ts +32 -0
  10. package/dist/parser/parse.js +758 -0
  11. package/dist/rules/no-backticks.d.ts +9 -0
  12. package/dist/rules/no-backticks.js +47 -0
  13. package/dist/rules/no-expansions-in-single-quotes.d.ts +9 -0
  14. package/dist/rules/no-expansions-in-single-quotes.js +43 -0
  15. package/dist/rules/no-ls-iteration.d.ts +9 -0
  16. package/dist/rules/no-ls-iteration.js +43 -0
  17. package/dist/rules/no-unquoted-expansions.d.ts +9 -0
  18. package/dist/rules/no-unquoted-expansions.js +91 -0
  19. package/dist/rules/no-unused-vars.d.ts +12 -0
  20. package/dist/rules/no-unused-vars.js +383 -0
  21. package/dist/rules/no-useless-cat.d.ts +9 -0
  22. package/dist/rules/no-useless-cat.js +77 -0
  23. package/dist/rules/no-useless-echo.d.ts +9 -0
  24. package/dist/rules/no-useless-echo.js +43 -0
  25. package/dist/rules/no-variables-in-printf-format.d.ts +9 -0
  26. package/dist/rules/no-variables-in-printf-format.js +61 -0
  27. package/dist/rules/require-cd-guard.d.ts +9 -0
  28. package/dist/rules/require-cd-guard.js +75 -0
  29. package/dist/rules/require-read-r.d.ts +9 -0
  30. package/dist/rules/require-read-r.js +84 -0
  31. package/dist/rules/utils.d.ts +35 -0
  32. package/dist/rules/utils.js +100 -0
  33. package/dist/types.d.ts +343 -0
  34. package/dist/types.js +7 -0
  35. package/dist/visitor-keys.d.ts +5 -0
  36. package/dist/visitor-keys.js +59 -0
  37. package/package.json +74 -4
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @fileoverview Rule to disallow piping from a single-file `cat`.
3
+ * Mirrors ShellCheck SC2002.
4
+ */
5
+ //------------------------------------------------------------------------------
6
+ // Imports
7
+ //------------------------------------------------------------------------------
8
+ import { getCommandName, getStaticText } from "./utils.js";
9
+ //------------------------------------------------------------------------------
10
+ // Helpers
11
+ //------------------------------------------------------------------------------
12
+ function hasPotentialExpansion(word) {
13
+ const checkParts = (parts, quoted = false) => parts.some(part => {
14
+ switch (part.type) {
15
+ case "Literal":
16
+ return (!quoted &&
17
+ (part.value.includes("*") ||
18
+ part.value.includes("?") ||
19
+ part.value.includes("[") ||
20
+ part.value.includes("{") ||
21
+ part.value.includes("}")));
22
+ case "SingleQuotedString":
23
+ return part.dollar;
24
+ case "DoubleQuotedString":
25
+ return checkParts(part.parts, true);
26
+ default:
27
+ return false;
28
+ }
29
+ });
30
+ return checkParts(word.parts);
31
+ }
32
+ //------------------------------------------------------------------------------
33
+ // Rule Definition
34
+ //------------------------------------------------------------------------------
35
+ const rule = {
36
+ meta: {
37
+ type: "suggestion",
38
+ docs: {
39
+ description: "Disallow useless `cat` at the start of a pipeline",
40
+ recommended: true,
41
+ url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-cat.md",
42
+ },
43
+ schema: [],
44
+ messages: {
45
+ uselessCat: "Useless cat. Consider 'cmd < file' or 'cmd file' instead. (ShellCheck SC2002)",
46
+ },
47
+ },
48
+ create(context) {
49
+ return {
50
+ Pipeline(node) {
51
+ const first = node.commands[0];
52
+ if (!first ||
53
+ first.type !== "Command" ||
54
+ getCommandName(first) !== "cat" ||
55
+ first.redirects.length > 0 ||
56
+ first.arguments.length !== 1) {
57
+ return;
58
+ }
59
+ const argument = first.arguments[0];
60
+ const text = argument ? getStaticText(argument) : null;
61
+ // Skip flags, stdin markers, and arguments that can expand to
62
+ // multiple words.
63
+ if (text === null ||
64
+ text.startsWith("-") ||
65
+ !argument ||
66
+ hasPotentialExpansion(argument)) {
67
+ return;
68
+ }
69
+ context.report({
70
+ node: first,
71
+ messageId: "uselessCat",
72
+ });
73
+ },
74
+ };
75
+ },
76
+ };
77
+ export default rule;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @fileoverview Rule to disallow `$(echo ...)` command substitutions.
3
+ * Mirrors ShellCheck SC2116.
4
+ */
5
+ import type { ShellRuleDefinition } from "../types.js";
6
+ declare const rule: ShellRuleDefinition<{
7
+ MessageIds: "uselessEcho";
8
+ }>;
9
+ export default rule;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @fileoverview Rule to disallow `$(echo ...)` command substitutions.
3
+ * Mirrors ShellCheck SC2116.
4
+ */
5
+ //------------------------------------------------------------------------------
6
+ // Imports
7
+ //------------------------------------------------------------------------------
8
+ import { isCommandNamed } from "./utils.js";
9
+ //------------------------------------------------------------------------------
10
+ // Rule Definition
11
+ //------------------------------------------------------------------------------
12
+ const rule = {
13
+ meta: {
14
+ type: "suggestion",
15
+ docs: {
16
+ description: "Disallow useless `echo` inside command substitutions",
17
+ recommended: true,
18
+ url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-echo.md",
19
+ },
20
+ schema: [],
21
+ messages: {
22
+ uselessEcho: "Useless echo? Instead of 'cmd $(echo foo)', just use 'cmd foo'. (ShellCheck SC2116)",
23
+ },
24
+ },
25
+ create(context) {
26
+ return {
27
+ CommandSubstitution(node) {
28
+ const first = node.body[0];
29
+ if (node.body.length === 1 &&
30
+ first !== undefined &&
31
+ isCommandNamed(first, "echo") &&
32
+ !first.negated &&
33
+ first.redirects.length === 0) {
34
+ context.report({
35
+ node,
36
+ messageId: "uselessEcho",
37
+ });
38
+ }
39
+ },
40
+ };
41
+ },
42
+ };
43
+ export default rule;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @fileoverview Rule to disallow variables in the printf format string.
3
+ * Mirrors ShellCheck SC2059.
4
+ */
5
+ import type { ShellRuleDefinition } from "../types.js";
6
+ declare const rule: ShellRuleDefinition<{
7
+ MessageIds: "variableInFormat";
8
+ }>;
9
+ export default rule;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @fileoverview Rule to disallow variables in the printf format string.
3
+ * Mirrors ShellCheck SC2059.
4
+ */
5
+ //------------------------------------------------------------------------------
6
+ // Imports
7
+ //------------------------------------------------------------------------------
8
+ import { getCommandName, getExpansions, getStaticText } from "./utils.js";
9
+ //------------------------------------------------------------------------------
10
+ // Rule Definition
11
+ //------------------------------------------------------------------------------
12
+ const rule = {
13
+ meta: {
14
+ type: "problem",
15
+ docs: {
16
+ description: "Disallow variables in the printf format string; use %s placeholders instead",
17
+ recommended: true,
18
+ url: "https://github.com/eslint/shell/blob/main/docs/rules/no-variables-in-printf-format.md",
19
+ },
20
+ schema: [],
21
+ messages: {
22
+ variableInFormat: "Don't use variables in the printf format string. Use printf '...%s...' \"$foo\". (ShellCheck SC2059)",
23
+ },
24
+ },
25
+ create(context) {
26
+ return {
27
+ Command(node) {
28
+ if (getCommandName(node) !== "printf") {
29
+ return;
30
+ }
31
+ let formatIndex = 0;
32
+ const first = node.arguments[0]
33
+ ? getStaticText(node.arguments[0])
34
+ : null;
35
+ if (first === "-v") {
36
+ // `printf -v var format ...`
37
+ formatIndex = 2;
38
+ }
39
+ else if (first !== null && first.startsWith("-v")) {
40
+ // `printf -vvar format ...`
41
+ formatIndex = 1;
42
+ }
43
+ const possibleFormat = node.arguments[formatIndex];
44
+ if (possibleFormat && getStaticText(possibleFormat) === "--") {
45
+ formatIndex++;
46
+ }
47
+ const format = node.arguments[formatIndex];
48
+ if (!format) {
49
+ return;
50
+ }
51
+ if (getExpansions(format, { includeQuoted: true }).length > 0) {
52
+ context.report({
53
+ node: format,
54
+ messageId: "variableInFormat",
55
+ });
56
+ }
57
+ },
58
+ };
59
+ },
60
+ };
61
+ export default rule;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @fileoverview Rule to require handling `cd` failure.
3
+ * Mirrors ShellCheck SC2164.
4
+ */
5
+ import type { ShellRuleDefinition } from "../types.js";
6
+ declare const rule: ShellRuleDefinition<{
7
+ MessageIds: "uncheckedCd" | "addGuard";
8
+ }>;
9
+ export default rule;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @fileoverview Rule to require handling `cd` failure.
3
+ * Mirrors ShellCheck SC2164.
4
+ */
5
+ //------------------------------------------------------------------------------
6
+ // Imports
7
+ //------------------------------------------------------------------------------
8
+ import { getCommandName } from "./utils.js";
9
+ //------------------------------------------------------------------------------
10
+ // Rule Definition
11
+ //------------------------------------------------------------------------------
12
+ const rule = {
13
+ meta: {
14
+ type: "problem",
15
+ docs: {
16
+ description: "Require `cd` failures to be handled, e.g. `cd ... || exit`",
17
+ recommended: true,
18
+ url: "https://github.com/eslint/shell/blob/main/docs/rules/require-cd-guard.md",
19
+ },
20
+ hasSuggestions: true,
21
+ schema: [],
22
+ messages: {
23
+ uncheckedCd: "Use 'cd ... || exit' or 'cd ... || return' in case cd fails. (ShellCheck SC2164)",
24
+ addGuard: "Add '|| exit' after the cd command.",
25
+ },
26
+ },
27
+ create(context) {
28
+ const { sourceCode } = context;
29
+ return {
30
+ Command(node) {
31
+ if (getCommandName(node) !== "cd") {
32
+ return;
33
+ }
34
+ // `! cd ...` uses the exit status explicitly.
35
+ if (node.negated) {
36
+ return;
37
+ }
38
+ let expression = node;
39
+ let parent = sourceCode.getParent(expression);
40
+ while (parent?.type === "LogicalExpression") {
41
+ // A failed command can propagate through a left-associative
42
+ // chain to an expression whose status is checked.
43
+ if (parent.left === expression) {
44
+ return;
45
+ }
46
+ expression = parent;
47
+ parent = sourceCode.getParent(expression);
48
+ }
49
+ if (parent) {
50
+ // `if cd ...`, `while cd ...`, `until cd ...`
51
+ if ((parent.type === "IfStatement" ||
52
+ parent.type === "WhileStatement" ||
53
+ parent.type === "UntilStatement") &&
54
+ parent.test.includes(node)) {
55
+ return;
56
+ }
57
+ }
58
+ const canSuggest = !node.background && node.redirects.length === 0;
59
+ context.report({
60
+ node,
61
+ messageId: "uncheckedCd",
62
+ suggest: canSuggest
63
+ ? [
64
+ {
65
+ messageId: "addGuard",
66
+ fix: fixer => fixer.insertTextAfter(node, " || exit"),
67
+ },
68
+ ]
69
+ : [],
70
+ });
71
+ },
72
+ };
73
+ },
74
+ };
75
+ export default rule;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @fileoverview Rule to require the `-r` flag when using `read`.
3
+ * Mirrors ShellCheck SC2162.
4
+ */
5
+ import type { ShellRuleDefinition } from "../types.js";
6
+ declare const rule: ShellRuleDefinition<{
7
+ MessageIds: "missingR";
8
+ }>;
9
+ export default rule;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview Rule to require the `-r` flag when using `read`.
3
+ * Mirrors ShellCheck SC2162.
4
+ */
5
+ //------------------------------------------------------------------------------
6
+ // Imports
7
+ //------------------------------------------------------------------------------
8
+ import { getCommandName, getStaticText } from "./utils.js";
9
+ //------------------------------------------------------------------------------
10
+ // Helpers
11
+ //------------------------------------------------------------------------------
12
+ const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]);
13
+ //------------------------------------------------------------------------------
14
+ // Rule Definition
15
+ //------------------------------------------------------------------------------
16
+ const rule = {
17
+ meta: {
18
+ type: "problem",
19
+ docs: {
20
+ description: "Require `read -r` so backslashes are not mangled",
21
+ recommended: true,
22
+ url: "https://github.com/eslint/shell/blob/main/docs/rules/require-read-r.md",
23
+ },
24
+ fixable: "code",
25
+ schema: [],
26
+ messages: {
27
+ missingR: "read without -r will mangle backslashes. (ShellCheck SC2162)",
28
+ },
29
+ },
30
+ create(context) {
31
+ return {
32
+ Command(node) {
33
+ if (getCommandName(node) !== "read" || !node.name) {
34
+ return;
35
+ }
36
+ let hasRawOption = false;
37
+ for (let index = 0; index < node.arguments.length; index++) {
38
+ const argument = node.arguments[index];
39
+ if (!argument) {
40
+ continue;
41
+ }
42
+ const text = getStaticText(argument);
43
+ if (text === "--") {
44
+ break;
45
+ }
46
+ if (text === null ||
47
+ !text.startsWith("-") ||
48
+ text === "-") {
49
+ continue;
50
+ }
51
+ for (let optionIndex = 1; optionIndex < text.length; optionIndex++) {
52
+ const option = text[optionIndex];
53
+ if (!option) {
54
+ break;
55
+ }
56
+ if (option === "r") {
57
+ hasRawOption = true;
58
+ break;
59
+ }
60
+ if (optionsWithArguments.has(option)) {
61
+ if (optionIndex === text.length - 1) {
62
+ index++;
63
+ }
64
+ break;
65
+ }
66
+ }
67
+ if (hasRawOption) {
68
+ break;
69
+ }
70
+ }
71
+ if (hasRawOption) {
72
+ return;
73
+ }
74
+ const name = node.name;
75
+ context.report({
76
+ node: name,
77
+ messageId: "missingR",
78
+ fix: fixer => fixer.insertTextAfter(name, " -r"),
79
+ });
80
+ },
81
+ };
82
+ },
83
+ };
84
+ export default rule;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @fileoverview Shared helpers for shell rules.
3
+ */
4
+ import type { CommandNode, CommandSubstitutionNode, ParameterExpansionNode, StatementNode, WordNode } from "../types.js";
5
+ /**
6
+ * Returns the plain text of a word when it is fully static (made up only of
7
+ * literals and quoted literals), or `null` when the word contains
8
+ * expansions. Quotes are removed from the result.
9
+ *
10
+ * Strings whose runtime text can differ from their source text are not
11
+ * static: `$'...'` strings containing escapes, which are not decoded, and
12
+ * locale-translated `$"..."` strings.
13
+ */
14
+ export declare function getStaticText(word: WordNode): string | null;
15
+ /**
16
+ * Returns the name of a command when it is a static word, or `null`.
17
+ */
18
+ export declare function getCommandName(command: CommandNode): string | null;
19
+ /**
20
+ * Determines whether a command node is a simple command with the given name.
21
+ */
22
+ export declare function isCommandNamed(statement: StatementNode, name: string): statement is CommandNode;
23
+ /**
24
+ * Determines whether a command substitution starts with a command of the given
25
+ * name.
26
+ */
27
+ export declare function startsWithCommand(body: StatementNode[], name: string): boolean;
28
+ /**
29
+ * Returns the expansions that make up a word, including those nested inside
30
+ * double quotes when `includeQuoted` is `true`. Expansions nested inside
31
+ * another expansion, such as `$b` in `${a:-$b}`, are not returned.
32
+ */
33
+ export declare function getExpansions(word: WordNode, { includeQuoted }?: {
34
+ includeQuoted?: boolean | undefined;
35
+ }): (ParameterExpansionNode | CommandSubstitutionNode)[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @fileoverview Shared helpers for shell rules.
3
+ */
4
+ //------------------------------------------------------------------------------
5
+ // Helpers
6
+ //------------------------------------------------------------------------------
7
+ /**
8
+ * Returns the plain text of a word when it is fully static (made up only of
9
+ * literals and quoted literals), or `null` when the word contains
10
+ * expansions. Quotes are removed from the result.
11
+ *
12
+ * Strings whose runtime text can differ from their source text are not
13
+ * static: `$'...'` strings containing escapes, which are not decoded, and
14
+ * locale-translated `$"..."` strings.
15
+ */
16
+ export function getStaticText(word) {
17
+ let result = "";
18
+ for (const part of word.parts) {
19
+ const text = getStaticPartText(part);
20
+ if (text === null) {
21
+ return null;
22
+ }
23
+ result += text;
24
+ }
25
+ return result;
26
+ }
27
+ function getStaticPartText(part) {
28
+ switch (part.type) {
29
+ case "Literal":
30
+ return part.value;
31
+ case "SingleQuotedString":
32
+ return part.dollar && part.value.includes("\\") ? null : part.value;
33
+ case "DoubleQuotedString": {
34
+ if (part.dollar) {
35
+ return null;
36
+ }
37
+ let result = "";
38
+ for (const inner of part.parts) {
39
+ const text = getStaticPartText(inner);
40
+ if (text === null) {
41
+ return null;
42
+ }
43
+ result += text;
44
+ }
45
+ return result;
46
+ }
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Returns the name of a command when it is a static word, or `null`.
53
+ */
54
+ export function getCommandName(command) {
55
+ if (!command.name) {
56
+ return null;
57
+ }
58
+ return getStaticText(command.name);
59
+ }
60
+ /**
61
+ * Determines whether a command node is a simple command with the given name.
62
+ */
63
+ export function isCommandNamed(statement, name) {
64
+ return statement.type === "Command" && getCommandName(statement) === name;
65
+ }
66
+ /**
67
+ * Determines whether a command substitution starts with a command of the given
68
+ * name.
69
+ */
70
+ export function startsWithCommand(body, name) {
71
+ const first = body[0];
72
+ if (!first) {
73
+ return false;
74
+ }
75
+ const firstCommand = first.type === "Pipeline" ? first.commands[0] : first;
76
+ return firstCommand !== undefined && isCommandNamed(firstCommand, name);
77
+ }
78
+ /**
79
+ * Returns the expansions that make up a word, including those nested inside
80
+ * double quotes when `includeQuoted` is `true`. Expansions nested inside
81
+ * another expansion, such as `$b` in `${a:-$b}`, are not returned.
82
+ */
83
+ export function getExpansions(word, { includeQuoted = false } = {}) {
84
+ const expansions = [];
85
+ const visitParts = (parts, quoted) => {
86
+ for (const part of parts) {
87
+ if (part.type === "ParameterExpansion" ||
88
+ part.type === "CommandSubstitution") {
89
+ if (!quoted || includeQuoted) {
90
+ expansions.push(part);
91
+ }
92
+ }
93
+ else if (part.type === "DoubleQuotedString") {
94
+ visitParts(part.parts, true);
95
+ }
96
+ }
97
+ };
98
+ visitParts(word.parts, false);
99
+ return expansions;
100
+ }