Repository: eslint/eslint
Pull request #15818 (2022-04-28 11:31:36)

feat: add `enforceInClassFields` option to no-underscore-dangle

<!--
    Thank you for contributing!

    ESLint adheres to the [JS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

#### What is the purpose of this pull request? (put an "X" next to an item)

<!--
    The following template is intentionally not a markdown checkbox list for the reasons
    explained in https://github.com/eslint/eslint/pull/12848#issuecomment-580302888
-->

[ ] Documentation update
[x] Bug fix ([template](https://raw.githubusercontent.com/eslint/eslint/HEAD/templates/bug-report.md))
[ ] New rule ([template](https://raw.githubusercontent.com/eslint/eslint/HEAD/templates/rule-proposal.md))
[ ] Changes an existing rule ([template](https://raw.githubusercontent.com/eslint/eslint/HEAD/templates/rule-change-proposal.md))
[ ] Add autofix to a rule
[ ] Add a CLI option
[ ] Add something to the core
[ ] Other, please explain:

<!--
    If the item you've checked above has a template, please paste the template questions below and answer them. (If this pull request is addressing an issue, you can just paste a link to the issue here instead.)
-->

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/developer-guide/contributing/pull-requests)
    - Include tests for this change
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What changes did you make? (Give an overview)
- This fix and address https://github.com/eslint/eslint/issues/15810 and adds a new option `enforceInClassFields`. 
- Added test cases and passing.
- Changed documentation accordingly.

#### Is there anything you'd like reviewers to focus on?
Discussion about this fix is on https://github.com/eslint/eslint/issues/15810
<!-- markdownlint-disable-file MD004 -->


eslint-github-bot[bot]: Hi @robertotcestari!, thanks for the Pull Request

The **first commit message** isn't properly formatted. We ask that you update the message to match this format, as we use it to generate changelogs and automate releases.

- The length of the commit message must be less than or equal to 72

**To Fix:** You can fix this problem by running `git commit --amend`, editing your commit message, and then running `git push -f` to update this pull request.

Read more about contributing to ESLint [here](https://eslint.org/docs/developer-guide/contributing/)


Patch:
diff --git a/README.md b/README.md
index df8dfd90e..4ad24079b 100644
--- a/README.md
+++ b/README.md
@@ -297,9 +297,9 @@ The following companies, organizations, and individuals support ESLint's ongoing
 <!--sponsorsstart-->
 <h3>Platinum Sponsors</h3>
 <p><a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="undefined"></a></p><h3>Gold Sponsors</h3>
-<p><a href="https://contra.com"><img src="https://images.opencollective.com/contra1/c70f93f/logo.png" alt="Contra" height="96"></a> <a href="https://nx.dev"><img src="https://images.opencollective.com/nx/0efbe42/logo.png" alt="Nx (by Nrwl)" height="96"></a> <a href="https://www.salesforce.com"><img src="https://images.opencollective.com/salesforce/ca8f997/logo.png" alt="Salesforce" height="96"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="96"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="96"></a> <a href="https://substack.com/"><img src="https://avatars.githubusercontent.com/u/53023767?v=4" alt="Substack" height="96"></a></p><h3>Silver Sponsors</h3>
+<p><a href="https://contra.com"><img src="https://images.opencollective.com/contra1/c70f93f/logo.png" alt="Contra" height="96"></a> <a href="https://www.salesforce.com"><img src="https://images.opencollective.com/salesforce/ca8f997/logo.png" alt="Salesforce" height="96"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="96"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="96"></a> <a href="https://substack.com/"><img src="https://avatars.githubusercontent.com/u/53023767?v=4" alt="Substack" height="96"></a></p><h3>Silver Sponsors</h3>
 <p><a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a></p><h3>Bronze Sponsors</h3>
-<p><a href="https://launchdarkly.com"><img src="https://images.opencollective.com/launchdarkly/574bb9e/logo.png" alt="launchdarkly" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://www.vpsserver.com"><img src="https://images.opencollective.com/vpsservercom/logo.png" alt="VPS" height="32"></a> <a href="https://icons8.com"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8: free icons, photos, illustrations, and music" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://themeisle.com"><img src="https://images.opencollective.com/themeisle/d5592fe/logo.png" alt="ThemeIsle" height="32"></a> <a href="https://www.ignitionapp.com"><img src="https://avatars.githubusercontent.com/u/5753491?v=4" alt="Ignition" height="32"></a></p>
+<p><a href="https://launchdarkly.com"><img src="https://images.opencollective.com/launchdarkly/574bb9e/logo.png" alt="launchdarkly" height="32"></a> <a href="https://nx.dev"><img src="https://images.opencollective.com/nx/0efbe42/logo.png" alt="Nx (by Nrwl)" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://www.vpsserver.com"><img src="https://images.opencollective.com/vpsservercom/logo.png" alt="VPS" height="32"></a> <a href="https://icons8.com"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8: free icons, photos, illustrations, and music" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://themeisle.com"><img src="https://images.opencollective.com/themeisle/d5592fe/logo.png" alt="ThemeIsle" height="32"></a> <a href="https://www.ignitionapp.com"><img src="https://avatars.githubusercontent.com/u/5753491?v=4" alt="Ignition" height="32"></a></p>
 <!--sponsorsend-->
 
 ## <a name="technology-sponsors"></a>Technology Sponsors
diff --git a/docs/src/developer-guide/working-with-rules.md b/docs/src/developer-guide/working-with-rules.md
index f1330ac12..e8d5a22bf 100644
--- a/docs/src/developer-guide/working-with-rules.md
+++ b/docs/src/developer-guide/working-with-rules.md
@@ -11,7 +11,7 @@ Each rule in ESLint has three files named with its identifier (for example, `no-
 
 * in the `lib/rules` directory: a source file (for example, `no-extra-semi.js`)
 * in the `tests/lib/rules` directory: a test file (for example, `no-extra-semi.js`)
-* in the `docs/rules` directory: a Markdown documentation file (for example, `no-extra-semi`)
+* in the `docs/src/rules` directory: a Markdown documentation file (for example, `no-extra-semi.md`)
 
 **Important:** If you submit a **core** rule to the ESLint repository, you **must** follow some conventions explained below.
 
@@ -541,7 +541,6 @@ Once you have an instance of `SourceCode`, you can use the following methods on
 * `getCommentsBefore(nodeOrToken)` - returns an array of comment tokens that occur directly before the given node or token.
 * `getCommentsAfter(nodeOrToken)` - returns an array of comment tokens that occur directly after the given node or token.
 * `getCommentsInside(node)` - returns an array of all comment tokens inside a given node.
-* `getJSDocComment(node)` - returns the JSDoc comment for a given node or `null` if there is none.
 * `isSpaceBetween(nodeOrToken, nodeOrToken)` - returns true if there is a whitespace character between the two tokens or, if given a node, the last token of the first node and the first token of the second node.
 * `getFirstToken(node, skipOptions)` - returns the first token representing the given node.
 * `getFirstTokens(node, countOptions)` - returns the first `count` tokens representing the given node.
@@ -598,6 +597,7 @@ Please note that the following methods have been deprecated and will be removed
 * `getTokenOrCommentBefore()` - replaced by `getTokenBefore()` with the `{ includeComments: true }` option
 * `getTokenOrCommentAfter()` - replaced by `getTokenAfter()` with the `{ includeComments: true }` option
 * `isSpaceBetweenTokens()` - replaced by `isSpaceBetween()`
+* `getJSDocComment()`
 
 ### Options Schemas
 
diff --git a/docs/src/rules/no-control-regex.md b/docs/src/rules/no-control-regex.md
index feebe4661..542e8f391 100644
--- a/docs/src/rules/no-control-regex.md
+++ b/docs/src/rules/no-control-regex.md
@@ -9,19 +9,31 @@ rule_type: problem
 
 Disallows control characters in regular expressions.
 
-Control characters are special, invisible characters in the ASCII range 0-31. These characters are rarely used in JavaScript strings so a regular expression containing these characters is most likely a mistake.
+Control characters are special, invisible characters in the ASCII range 0-31. These characters are rarely used in JavaScript strings so a regular expression containing elements that explicitly match these characters is most likely a mistake.
 
 ## Rule Details
 
-This rule disallows control characters in regular expressions.
+This rule disallows control characters and some escape sequences that match control characters in regular expressions.
+
+The following elements of regular expression patterns are considered possible errors in typing and are therefore disallowed by this rule:
+
+* Hexadecimal character escapes from `\x00` to `\x1F`.
+* Unicode character escapes from `\u0000` to `\u001F`.
+* Unescaped raw characters from U+0000 to U+001F.
+
+Control escapes such as `\t` and `\n` are allowed by this rule.
 
 Examples of **incorrect** code for this rule:
 
 ```js
 /*eslint no-control-regex: "error"*/
 
-var pattern1 = /\x1f/;
-var pattern2 = new RegExp("\x1f");
+var pattern1 = /\x00/;
+var pattern2 = /\x0C/;
+var pattern3 = /\x1F/;
+var pattern4 = /\u000C/;
+var pattern5 = new RegExp("\x0C"); // raw U+000C character in the pattern
+var pattern6 = new RegExp("\\x0C"); // \x0C pattern
 ```
 
 Examples of **correct** code for this rule:
@@ -30,9 +42,28 @@ Examples of **correct** code for this rule:
 /*eslint no-control-regex: "error"*/
 
 var pattern1 = /\x20/;
-var pattern2 = new RegExp("\x20");
+var pattern2 = /\u0020/;
+var pattern3 = /\t/;
+var pattern4 = /\n/;
+var pattern5 = new RegExp("\x20");
+var pattern6 = new RegExp("\\t");
+var pattern7 = new RegExp("\\n");
+```
+
+## Known Limitations
+
+When checking `RegExp` constructor calls, this rule examines evaluated regular expression patterns. Therefore, although this rule intends to allow syntax such as `\t`, it doesn't allow `new RegExp("\t")` since the evaluated pattern (string value of `"\t"`) contains a raw control character (the TAB character).
+
+```js
+/*eslint no-control-regex: "error"*/
+
+new RegExp("\t"); // disallowed since the pattern is: <TAB>
+
+new RegExp("\\t"); // allowed since the pattern is: \t
 ```
 
+There is no difference in behavior between `new RegExp("\t")` and `new RegExp("\\t")`, and the intention to match the TAB character is clear in both cases. They are equally valid for the purpose of this rule, but it only allows `new RegExp("\\t")`.
+
 ## When Not To Use It
 
 If you need to use control character pattern matching, then you should turn this rule off.
diff --git a/docs/src/rules/no-underscore-dangle.md b/docs/src/rules/no-underscore-dangle.md
index 73b132446..15f082629 100644
--- a/docs/src/rules/no-underscore-dangle.md
+++ b/docs/src/rules/no-underscore-dangle.md
@@ -54,6 +54,7 @@ This rule has an object option:
 * `"allowAfterSuper": false` (default) disallows dangling underscores in members of the `super` object
 * `"allowAfterThisConstructor": false` (default) disallows dangling underscores in members of the `this.constructor` object
 * `"enforceInMethodNames": false` (default) allows dangling underscores in method names
+* `"enforceInClassFields": false` (default) allows dangling underscores in es2022 class fields names
 * `"allowFunctionParams": true` (default) allows dangling underscores in function parameter names
 
 ### allow
@@ -124,6 +125,34 @@ const o = {
 };
 ```
 
+### enforceInClassFields
+
+Examples of **incorrect** code for this rule with the `{ "enforceInClassFields": true }` option:
+
+```js
+/*eslint no-underscore-dangle: ["error", { "enforceInClassFields": true }]*/
+
+class Foo {
+    _bar;
+}
+
+class Foo {
+    _bar = () => {};
+}
+
+class Foo {
+    bar_;
+}
+
+class Foo {
+    #_bar;
+}
+
+class Foo {
+    #bar_;
+}
+```
+
 ### allowFunctionParams
 
 Examples of **incorrect** code for this rule with the `{ "allowFunctionParams": false }` option:
diff --git a/docs/src/rules/prefer-object-spread.md b/docs/src/rules/prefer-object-spread.md
index fe6bad8aa..dd4958676 100644
--- a/docs/src/rules/prefer-object-spread.md
+++ b/docs/src/rules/prefer-object-spread.md
@@ -20,17 +20,15 @@ Examples of **incorrect** code for this rule:
 ```js
 /*eslint prefer-object-spread: "error"*/
 
-Object.assign({}, foo)
+Object.assign({}, foo);
 
-Object.assign({}, {foo: 'bar'})
+Object.assign({}, {foo: 'bar'});
 
-Object.assign({ foo: 'bar'}, baz)
+Object.assign({ foo: 'bar'}, baz);
 
-Object.assign({ foo: 'bar' }, Object.assign({ bar: 'foo' }))
+Object.assign({}, baz, { foo: 'bar' });
 
-Object.assign({}, { foo, bar, baz })
-
-Object.assign({}, { ...baz })
+Object.assign({}, { ...baz });
 
 // Object.assign with a single argument that is an object literal
 Object.assign({});
@@ -43,14 +41,16 @@ Examples of **correct** code for this rule:
 ```js
 /*eslint prefer-object-spread: "error"*/
 
-Object.assign(...foo);
+({ ...foo });
+
+({ ...baz, foo: 'bar' });
 
 // Any Object.assign call without an object literal as the first argument
 Object.assign(foo, { bar: baz });
 
-Object.assign(foo, Object.assign(bar));
+Object.assign(foo, bar);
 
-Object.assign(foo, { bar, baz })
+Object.assign(foo, { bar, baz });
 
 Object.assign(foo, { ...baz });
 ```
diff --git a/lib/rules/no-underscore-dangle.js b/lib/rules/no-underscore-dangle.js
index 0ab41feb0..55fb9b8cf 100644
--- a/lib/rules/no-underscore-dangle.js
+++ b/lib/rules/no-underscore-dangle.js
@@ -49,6 +49,10 @@ module.exports = {
                     allowFunctionParams: {
                         type: "boolean",
                         default: true
+                    },
+                    enforceInClassFields: {
+                        type: "boolean",
+                        default: false
                     }
                 },
                 additionalProperties: false
@@ -68,6 +72,7 @@ module.exports = {
         const allowAfterSuper = typeof options.allowAfterSuper !== "undefined" ? options.allowAfterSuper : false;
         const allowAfterThisConstructor = typeof options.allowAfterThisConstructor !== "undefined" ? options.allowAfterThisConstructor : false;
         const enforceInMethodNames = typeof options.enforceInMethodNames !== "undefined" ? options.enforceInMethodNames : false;
+        const enforceInClassFields = typeof options.enforceInClassFields !== "undefined" ? options.enforceInClassFields : false;
         const allowFunctionParams = typeof options.allowFunctionParams !== "undefined" ? options.allowFunctionParams : true;
 
         //-------------------------------------------------------------------------
@@ -261,6 +266,30 @@ module.exports = {
             }
         }
 
+        /**
+         * Check if a class field has a dangling underscore
+         * @param {ASTNode} node node to evaluate
+         * @returns {void}
+         * @private
+         */
+        function checkForDanglingUnderscoreInClassField(node) {
+            const identifier = node.key.name;
+
+            if (typeof identifier !== "undefined" && hasDanglingUnderscore(identifier) &&
+                enforceInClassFields &&
+                !isAllowed(identifier)) {
+                context.report({
+                    node,
+                    messageId: "unexpectedUnderscore",
+                    data: {
+                        identifier: node.key.type === "PrivateIdentifier"
+                            ? `#${identifier}`
+                            : identifier
+                    }
+                });
+            }
+        }
+
         //--------------------------------------------------------------------------
         // Public API
         //--------------------------------------------------------------------------
@@ -270,7 +299,7 @@ module.exports = {
             VariableDeclarator: checkForDanglingUnderscoreInVariableExpression,
             MemberExpression: checkForDanglingUnderscoreInMemberExpression,
             MethodDefinition: checkForDanglingUnderscoreInMethod,
-            PropertyDefinition: checkForDanglingUnderscoreInMethod,
+            PropertyDefinition: checkForDanglingUnderscoreInClassField,
             Property: checkForDanglingUnderscoreInMethod,
             FunctionExpression: checkForDanglingUnderscoreInFunction,
             ArrowFunctionExpression: checkForDanglingUnderscoreInFunction


Test patch:
diff --git a/tests/lib/rules/no-underscore-dangle.js b/tests/lib/rules/no-underscore-dangle.js
index c83a5b3fe..f3e11cc3e 100644
--- a/tests/lib/rules/no-underscore-dangle.js
+++ b/tests/lib/rules/no-underscore-dangle.js
@@ -71,7 +71,10 @@ ruleTester.run("no-underscore-dangle", rule, {
         { code: "function foo( { _bar = 0 } = {}) {}", options: [{ allowFunctionParams: false }], parserOptions: { ecmaVersion: 6 } },
         { code: "function foo(...[_bar]) {}", options: [{ allowFunctionParams: false }], parserOptions: { ecmaVersion: 2016 } },
         { code: "class foo { _field; }", parserOptions: { ecmaVersion: 2022 } },
-        { code: "class foo { #_field; }", parserOptions: { ecmaVersion: 2022 } }
+        { code: "class foo { _field; }", options: [{ enforceInClassFields: false }], parserOptions: { ecmaVersion: 2022 } },
+        { code: "class foo { #_field; }", parserOptions: { ecmaVersion: 2022 } },
+        { code: "class foo { #_field; }", options: [{ enforceInClassFields: false }], parserOptions: { ecmaVersion: 2022 } },
+        { code: "class foo { _field; }", options: [{}], parserOptions: { ecmaVersion: 2022 } }
     ],
     invalid: [
         { code: "var _foo = 1", errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "_foo" }, type: "VariableDeclarator" }] },
@@ -109,6 +112,30 @@ ruleTester.run("no-underscore-dangle", rule, {
             options: [{ enforceInMethodNames: true }],
             parserOptions: { ecmaVersion: 2022 },
             errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "#bar_" } }]
+        },
+        {
+            code: "class foo { _field; }",
+            options: [{ enforceInClassFields: true }],
+            parserOptions: { ecmaVersion: 2022 },
+            errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "_field" } }]
+        },
+        {
+            code: "class foo { #_field; }",
+            options: [{ enforceInClassFields: true }],
+            parserOptions: { ecmaVersion: 2022 },
+            errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "#_field" } }]
+        },
+        {
+            code: "class foo { field_; }",
+            options: [{ enforceInClassFields: true }],
+            parserOptions: { ecmaVersion: 2022 },
+            errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "field_" } }]
+        },
+        {
+            code: "class foo { #field_; }",
+            options: [{ enforceInClassFields: true }],
+            parserOptions: { ecmaVersion: 2022 },
+            errors: [{ messageId: "unexpectedUnderscore", data: { identifier: "#field_" } }]
         }
     ]
 });