diff --git a/docs/rules/no-unsafe-optional-chaining.md b/docs/rules/no-unsafe-optional-chaining.md new file mode 100644 index 00000000000..f3d622c561d --- /dev/null +++ b/docs/rules/no-unsafe-optional-chaining.md @@ -0,0 +1,63 @@ +# disallow optional chaining that possibly errors (no-unsafe-optional-chaining) + +The optional chaining(`?.`) expression can short-circuit with `undefined`. Therefore, treating an evaluated optional chaining expression as a function, object, number, etc., can cause TypeError or unexpected results. + +## Rule Details + +This rule disallows some cases that might be an TypeError. + +Examples of **incorrect** code for this rule: + +```js +/*eslint no-unsafe-optional-chaining: "error"*/ + +(obj?.foo)(); + +(obj?.foo).bar; + +(obj?.foo)`template`; + +new (obj?.foo)(); + +[...obj?.foo]; + +bar(...obj?.foo); +``` + +Examples of **correct** code for this rule: + +```js +/*eslint no-unsafe-optional-chaining: "error"*/ + +(obj?.foo)?.(); + +obj?.foo?.bar; + +(obj?.foo ?? bar)`template`; + +new (obj?.foo ?? bar)(); + +var baz = {...obj.?foo}; +``` + +## Options + +This rule has an object option: + +- `disallowArithmeticOperators`: Disallow arithmetic operation on optional chaining expression (Default `false`). If this is `true`, this rule warns arithmetic operations on optional chaining expression which possibly result in `NaN`. + +### disallowArithmeticOperators + +Examples of additional **incorrect** code for this rule with the `{ "disallowArithmeticOperators": true }` option: + +```js +/*eslint no-unsafe-optional-chaining: ["error", { "disallowArithmeticOperators": true }]*/ + +obj?.foo + bar; + +obj?.foo * bar; + ++obj?.foo; + +baz += obj?.foo; +``` diff --git a/lib/rules/index.js b/lib/rules/index.js index 3cf26e51bc8..bace6558354 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -217,6 +217,7 @@ module.exports = new LazyLoadingRuleMap(Object.entries({ "no-unreachable-loop": () => require("./no-unreachable-loop"), "no-unsafe-finally": () => require("./no-unsafe-finally"), "no-unsafe-negation": () => require("./no-unsafe-negation"), + "no-unsafe-optional-chaining": () => require("./no-unsafe-optional-chaining"), "no-unused-expressions": () => require("./no-unused-expressions"), "no-unused-labels": () => require("./no-unused-labels"), "no-unused-vars": () => require("./no-unused-vars"), diff --git a/lib/rules/no-unsafe-optional-chaining.js b/lib/rules/no-unsafe-optional-chaining.js new file mode 100644 index 00000000000..fcbc21c5088 --- /dev/null +++ b/lib/rules/no-unsafe-optional-chaining.js @@ -0,0 +1,131 @@ +/** + * @fileoverview Rule to disallow unsafe optional chaining + * @author Yeon JuAn + */ + +"use strict"; + +const ARITHMETIC_OPERATORS = ["+", "-", "/", "*", "%", "**", "+=", "-=", "/=", "*=", "%=", "**="]; + +/** + * Checks whether a node is an arithmetic expression or not + * @param {ASTNode} node node to check + * @returns {boolean} `true` if a node is an arithmetic expression, otherwise `false` + */ +function isArithmeticExpression(node) { + return ( + node.type === "BinaryExpression" || + node.type === "UnaryExpression" || + node.type === "AssignmentExpression" + ) && ARITHMETIC_OPERATORS.includes(node.operator); +} + +/** + * Checks whether a node is a destructuring pattern or not + * @param {ASTNode} node node to check + * @returns {boolean} `true` if a node is a destructuring pattern, otherwise `false` + */ +function isDestructuringPattern(node) { + return node.type === "ObjectPattern" || node.type === "ArrayPattern"; +} + +/** + * Checks whether a ChainExpression make an runtime error or not + * @param {ASTNode} chainExp a ChainExpression node. + * @returns {boolean} `true` if it can be a runtime error, otherwise `false` + */ +function isPossiblyMakeRuntimeError(chainExp) { + const parent = chainExp.parent; + + switch (parent.type) { + case "CallExpression": + case "NewExpression": + return parent.callee === chainExp && parent.parent.type !== "ChainExpression"; + case "MemberExpression": + return parent.object === chainExp && parent.parent.type !== "ChainExpression"; + case "TaggedTemplateExpression": + return parent.tag === chainExp; + case "ClassDeclaration": + return parent.superClass === chainExp; + case "VariableDeclarator": + return isDestructuringPattern(parent.id) && parent.init === chainExp; + case "AssignmentExpression": + return isDestructuringPattern(parent.left) && parent.right === chainExp; + case "SpreadElement": + return parent.parent.type !== "ObjectExpression"; + default: + return false; + } +} + +module.exports = { + meta: { + type: "suggestion", + + docs: { + description: "disallow using unsafe-optional-chaining.", + category: "Possible Errors", + recommended: false, + url: "https://eslint.org/docs/rules/no-unsafe-optional-chaining" + }, + schema: [{ + type: "object", + properties: { + disallowArithmeticOperators: { + type: "boolean", + default: false + } + }, + additionalProperties: false + }], + fixable: null, + messages: { + unsafeOptionalChain: "Unsafe usage of {{node}}.", + unsafeArithmetic: "Unsafe arithmetic operation on {{node}}. It can result in NaN." + } + }, + + create(context) { + const options = context.options[0] || {}; + const disallowArithmeticOperators = (options.disallowArithmeticOperators) || false; + + /** + * Reports an error for unsafe optional chaining usage. + * @param {ASTNode} node node to report + * @returns {void} + */ + function reportUnsafeOptionalChain(node) { + context.report({ + messageId: "unsafeOptionalChain", + node + }); + } + + /** + * Reports an error for unsafe arithmetic operations on optional chaining. + * @param {ASTNode} node node to report + * @returns {void} + */ + function reportUnsafeArithmetic(node) { + context.report({ + messageId: "unsafeArithmetic", + node + }); + } + + return { + ChainExpression(node) { + if ( + disallowArithmeticOperators && + node.parent && + isArithmeticExpression(node.parent) + ) { + reportUnsafeArithmetic(node); + } + if (isPossiblyMakeRuntimeError(node)) { + reportUnsafeOptionalChain(node); + } + } + }; + } +}; diff --git a/tests/lib/rules/no-unsafe-optional-chaining.js b/tests/lib/rules/no-unsafe-optional-chaining.js new file mode 100644 index 00000000000..e198ad63acf --- /dev/null +++ b/tests/lib/rules/no-unsafe-optional-chaining.js @@ -0,0 +1,390 @@ +/** + * @fileoverview Tests for no-unsafe-optional-chaining rule. + * @author Yeon JuAn + */ + +"use strict"; + +const rule = require("../../../lib/rules/no-unsafe-optional-chaining"); + +const { RuleTester } = require("../../../lib/rule-tester"); + +const parserOptions = { + ecmaVersion: 2021, + sourceType: "module" +}; + +const ruleTester = new RuleTester({ parserOptions }); + +ruleTester.run("no-unsafe-optional-chaining", rule, { + valid: [ + "obj?.foo();", + "obj?.foo?.();", + "(obj?.foo ?? bar)();", + "(obj?.foo)?.()", + "(obj.foo)?.();", + "obj?.foo.bar;", + "obj?.foo?.bar;", + "(obj?.foo)?.bar;", + "(obj?.foo ?? bar).baz;", + "(obj?.foo ?? val)`template`", + "new (obj?.foo ?? val)()", + "obj?.foo?.()();", + "const {foo} = obj?.baz || {};", + "bar(...obj?.foo ?? []);", + + "var bar = {...foo?.bar};", + + // The default value option disallowArithmeticOperators is false + "obj?.foo - bar;", + "obj?.foo + bar;", + "obj?.foo * bar;", + "obj?.foo / bar;", + "obj?.foo % bar;", + "obj?.foo ** bar;", + + { + code: "(obj?.foo || baz) + bar;", + options: [{ + disallowArithmeticOperators: true + }] + }, + { + code: "(obj?.foo ?? baz) + bar;", + options: [{ + disallowArithmeticOperators: true + }] + }, + { + code: "bar += obj?.foo ?? val", + options: [{ + disallowArithmeticOperators: true + }] + } + ], + + invalid: [ + { + code: "(obj?.foo)();", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "(obj?.foo?.())();", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "(obj?.foo).bar", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "(obj?.foo)`template`", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "new (obj?.foo)();", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 6 + } + ] + }, + { + code: "new (obj?.foo?.())()", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 6 + } + ] + }, + { + code: "[...obj?.foo];", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 5 + } + ] + }, + { + code: "bar(...obj?.foo);", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 8 + } + ] + }, + { + code: "new Bar(...obj?.foo);", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 12 + } + ] + }, + { + code: "const {foo} = obj?.bar;", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 15 + } + ] + }, + { + code: "const {foo} = obj?.bar();", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 15 + } + ] + }, + { + code: "const [foo] = obj?.bar;", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 15 + } + ] + }, + { + code: "const [foo] = obj?.bar?.();", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 15 + } + ] + }, + { + code: "class A extends obj?.foo {}", + errors: [ + { + messageId: "unsafeOptionalChain", + type: "ChainExpression", + line: 1, + column: 17 + } + ] + }, + { + code: "obj?.foo + bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "obj?.foo - bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "obj?.foo * bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "obj?.foo / bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "obj?.foo % bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "obj?.foo ** bar;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 1 + } + ] + }, + { + code: "+obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "-obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 2 + } + ] + }, + { + code: "bar += obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 8 + } + ] + }, + { + code: "bar -= obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 8 + } + ] + }, + { + code: "bar %= obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 8 + } + ] + }, + { + code: "bar **= obj?.foo;", + options: [{ + disallowArithmeticOperators: true + }], + errors: [ + { + messageId: "unsafeArithmetic", + type: "ChainExpression", + line: 1, + column: 9 + } + ] + } + ] +}); diff --git a/tools/rule-types.json b/tools/rule-types.json index 84700de70d0..158e09a7d49 100644 --- a/tools/rule-types.json +++ b/tools/rule-types.json @@ -204,6 +204,7 @@ "no-unreachable-loop": "problem", "no-unsafe-finally": "problem", "no-unsafe-negation": "problem", + "no-unsafe-optional-chaining": "problem", "no-unused-expressions": "suggestion", "no-unused-labels": "suggestion", "no-unused-vars": "problem",