-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(eslint-plugin): [no-unused-private-class-members] new extension rule #10265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
91f3406
b2f0126
e37fd8b
819b8a5
c621697
389e1f2
1e04837
e82d5f5
57369ab
12c2c6d
76f1693
b8231a9
2e4b0e8
d430b17
597b25d
45320ab
684c009
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
--- | ||
description: 'Disallow unused private class members.' | ||
--- | ||
|
||
import Tabs from '@theme/Tabs'; | ||
import TabItem from '@theme/TabItem'; | ||
|
||
> 🛑 This file is source code, not the primary documentation location! 🛑 | ||
> | ||
> See **https://typescript-eslint.io/rules/no-unused-private-class-members** for documentation. | ||
|
||
This rule extends the base [`eslint/no-unused-private-class-members`](https://eslint.org/docs/rules/no-unused-private-class-members) rule. | ||
It adds support for members declared with TypeScript's `private` keyword. | ||
|
||
## Options | ||
|
||
This rule has no options. | ||
|
||
## Examples | ||
|
||
<Tabs> | ||
<TabItem value="❌ Incorrect"> | ||
|
||
```ts | ||
class A { | ||
private foo = 123; | ||
} | ||
``` | ||
|
||
</TabItem> | ||
<TabItem value="✅ Correct"> | ||
|
||
```tsx | ||
class A { | ||
private foo = 123; | ||
|
||
constructor() { | ||
console.log(this.foo); | ||
} | ||
} | ||
``` | ||
|
||
</TabItem> | ||
</Tabs> | ||
|
||
## When Not To Use It | ||
|
||
If you don’t want to be notified about unused private class members, you can safely turn this rule off. |
Zamiell marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,270 @@ | ||
// The following code is adapted from the code in eslint/eslint. | ||
// Source: https://github.com/eslint/eslint/blob/522d8a32f326c52886c531f43cf6a1ff15af8286/lib/rules/no-unused-private-class-members.js | ||
// License: https://github.com/eslint/eslint/blob/522d8a32f326c52886c531f43cf6a1ff15af8286/LICENSE | ||
|
||
import type { TSESTree } from '@typescript-eslint/utils'; | ||
|
||
import { AST_NODE_TYPES } from '@typescript-eslint/utils'; | ||
|
||
import { createRule } from '../util'; | ||
|
||
type Options = []; | ||
export type MessageIds = 'unusedPrivateClassMember'; | ||
|
||
interface PrivateMember { | ||
declaredNode: | ||
| TSESTree.MethodDefinitionComputedName | ||
| TSESTree.MethodDefinitionNonComputedName | ||
| TSESTree.PropertyDefinitionComputedName | ||
| TSESTree.PropertyDefinitionNonComputedName; | ||
isAccessor: boolean; | ||
} | ||
|
||
export default createRule<Options, MessageIds>({ | ||
name: 'no-unused-private-class-members', | ||
meta: { | ||
type: 'problem', | ||
docs: { | ||
description: 'Disallow unused private class members', | ||
extendsBaseRule: true, | ||
requiresTypeChecking: false, | ||
}, | ||
|
||
messages: { | ||
unusedPrivateClassMember: | ||
"'{{classMemberName}}' is defined but never used.", | ||
}, | ||
|
||
schema: [], | ||
}, | ||
defaultOptions: [], | ||
create(context) { | ||
const trackedClassMembers: Map<string, PrivateMember>[] = []; | ||
|
||
/** | ||
* The core ESLint rule tracks methods by adding an extra property of | ||
* "isUsed" to the method, which is a bug according to Joshua Goldberg. | ||
* Instead, in our extended rule, we create a separate data structure to | ||
* track whether a method is being used. | ||
*/ | ||
const trackedClassMembersUsed = new Set< | ||
| TSESTree.MethodDefinitionComputedName | ||
| TSESTree.MethodDefinitionNonComputedName | ||
| TSESTree.PropertyDefinitionComputedName | ||
| TSESTree.PropertyDefinitionNonComputedName | ||
>(); | ||
|
||
/** | ||
* Check whether the current node is in a write only assignment. | ||
* @param node Node referring to a private identifier | ||
* @returns Whether the node is in a write only assignment | ||
* @private | ||
*/ | ||
function isWriteOnlyAssignment( | ||
node: TSESTree.Identifier | TSESTree.PrivateIdentifier, | ||
): boolean { | ||
const parentStatement = node.parent.parent; | ||
if (parentStatement === undefined) { | ||
return false; | ||
} | ||
|
||
const isAssignmentExpression = | ||
parentStatement.type === AST_NODE_TYPES.AssignmentExpression; | ||
|
||
if ( | ||
!isAssignmentExpression && | ||
parentStatement.type !== AST_NODE_TYPES.ForInStatement && | ||
parentStatement.type !== AST_NODE_TYPES.ForOfStatement && | ||
parentStatement.type !== AST_NODE_TYPES.AssignmentPattern | ||
) { | ||
return false; | ||
} | ||
|
||
// It is a write-only usage, since we still allow usages on the right for | ||
// reads. | ||
if (parentStatement.left !== node.parent) { | ||
return false; | ||
} | ||
|
||
// For any other operator (such as '+=') we still consider it a read | ||
// operation. | ||
if (isAssignmentExpression && parentStatement.operator !== '=') { | ||
// However, if the read operation is "discarded" in an empty statement, | ||
// then we consider it write only. | ||
return ( | ||
parentStatement.parent.type === AST_NODE_TYPES.ExpressionStatement | ||
); | ||
} | ||
|
||
return true; | ||
} | ||
|
||
//-------------------------------------------------------------------------- | ||
// Public | ||
//-------------------------------------------------------------------------- | ||
|
||
function processPrivateIdentifier( | ||
node: TSESTree.Identifier | TSESTree.PrivateIdentifier, | ||
): void { | ||
const classBody = trackedClassMembers.find(classProperties => | ||
classProperties.has(node.name), | ||
); | ||
|
||
// Can't happen, as it is a parser error to have a missing class body, but | ||
// let's code defensively here. | ||
if (classBody === undefined) { | ||
return; | ||
} | ||
|
||
// In case any other usage was already detected, we can short circuit the | ||
// logic here. | ||
const memberDefinition = classBody.get(node.name); | ||
if (memberDefinition === undefined) { | ||
return; | ||
} | ||
if (trackedClassMembersUsed.has(memberDefinition.declaredNode)) { | ||
return; | ||
} | ||
|
||
// The definition of the class member itself | ||
if ( | ||
node.parent.type === AST_NODE_TYPES.PropertyDefinition || | ||
node.parent.type === AST_NODE_TYPES.MethodDefinition | ||
) { | ||
return; | ||
} | ||
|
||
// Any usage of an accessor is considered a read, as the getter/setter can | ||
// have side-effects in its definition. | ||
if (memberDefinition.isAccessor) { | ||
trackedClassMembersUsed.add(memberDefinition.declaredNode); | ||
return; | ||
} | ||
|
||
// Any assignments to this member, except for assignments that also read | ||
if (isWriteOnlyAssignment(node)) { | ||
return; | ||
} | ||
|
||
const wrappingExpressionType = node.parent.parent?.type; | ||
const parentOfWrappingExpressionType = node.parent.parent?.parent?.type; | ||
|
||
// A statement which only increments (`this.#x++;`) | ||
if ( | ||
wrappingExpressionType === AST_NODE_TYPES.UpdateExpression && | ||
parentOfWrappingExpressionType === AST_NODE_TYPES.ExpressionStatement | ||
) { | ||
return; | ||
} | ||
|
||
/* | ||
* ({ x: this.#usedInDestructuring } = bar); | ||
* | ||
* But should treat the following as a read: | ||
* ({ [this.#x]: a } = foo); | ||
*/ | ||
if ( | ||
wrappingExpressionType === AST_NODE_TYPES.Property && | ||
parentOfWrappingExpressionType === AST_NODE_TYPES.ObjectPattern && | ||
node.parent.parent?.value === node.parent | ||
) { | ||
return; | ||
} | ||
|
||
// [...this.#unusedInRestPattern] = bar; | ||
if (wrappingExpressionType === AST_NODE_TYPES.RestElement) { | ||
return; | ||
} | ||
|
||
// [this.#unusedInAssignmentPattern] = bar; | ||
if (wrappingExpressionType === AST_NODE_TYPES.ArrayPattern) { | ||
return; | ||
} | ||
|
||
// We can't delete the memberDefinition, as we need to keep track of which | ||
// member we are marking as used. In the case of nested classes, we only | ||
// mark the first member we encounter as used. If you were to delete the | ||
// member, then any subsequent usage could incorrectly mark the member of | ||
// an encapsulating parent class as used, which is incorrect. | ||
trackedClassMembersUsed.add(memberDefinition.declaredNode); | ||
} | ||
|
||
return { | ||
// Collect all declared members/methods up front and assume they are all | ||
// unused. | ||
ClassBody(classBodyNode): void { | ||
const privateMembers = new Map<string, PrivateMember>(); | ||
|
||
trackedClassMembers.unshift(privateMembers); | ||
for (const bodyMember of classBodyNode.body) { | ||
if ( | ||
(bodyMember.type === AST_NODE_TYPES.PropertyDefinition || | ||
bodyMember.type === AST_NODE_TYPES.MethodDefinition) && | ||
(bodyMember.key.type === AST_NODE_TYPES.PrivateIdentifier || | ||
(bodyMember.key.type === AST_NODE_TYPES.Identifier && | ||
bodyMember.accessibility === 'private')) | ||
) { | ||
privateMembers.set(bodyMember.key.name, { | ||
declaredNode: bodyMember, | ||
isAccessor: | ||
bodyMember.type === AST_NODE_TYPES.MethodDefinition && | ||
(bodyMember.kind === 'set' || bodyMember.kind === 'get'), | ||
}); | ||
} | ||
} | ||
}, | ||
|
||
// Process nodes like: | ||
// ```ts | ||
// class A { | ||
// #myPrivateMember = 123; | ||
// } | ||
// ``` | ||
PrivateIdentifier(node): void { | ||
processPrivateIdentifier(node); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Threading #10265 (comment): I don't get that error locally. But I do get what Nx is getting (https://cloud.nx.app/runs/lsSb3ZQa0g?utm_source=pull-request&utm_medium=comment):
Maybe a caching issue? I'd suggest merging from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I merged with main and now I am getting this Netlify error: https://app.netlify.com/sites/typescript-eslint/deploys/67295a26e190f200080d6053 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Furthermore, it looks like "docs.test.ts" has a lint error, but that is unrelated to my PR. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #10288 ✨ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I merged with main, but CI is now getting this error:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you are implying that I am committing too much, I thought it was best-practice to put each PR fix that was brought up in a PR review thread by other people into a separate commit, which I did. Anyways, I just merged from main again. Now, I get these two CI errors:
I am not sure what they mean though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't subscribe to that best practice 😄. It can be nice to have granular commits when there are tricky things being discussed. But there's no real utility most of the time. The downside is that a lot of CI churn gets added to the repo. Which is never good: almost all repos are either on a rate-limited free plan (like we are) or a pay-for-bandwidth plan ($$$).
You've got some test failures, have you tried reading through the test files mentioned in them? I haven't looked deeply but if you've got specific questions I can. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right, but the part I am confused about is that the test failures are in files that I haven't modified / not part of this PR. Do I have to add the new rule to one of the existing configs or something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's what I'm trying to hint at with my questions: did you read the tests that are failing? The test names, the test failures, the code they touch? Ideally, the repo's docs and tests should be set up so that you can debug through these and figure this out on your own. If it's not clear from them then that would be something we'd want to work on. I'm not intentionally trying to be obtuse or unhelpful. But these are things that I think as someone who's got a bunch of PRs merged into this repo (❤️) you're equipped to debug. |
||
}, | ||
|
||
// Process nodes like: | ||
// ```ts | ||
// class A { | ||
// private myPrivateMember = 123; | ||
// } | ||
// ``` | ||
PropertyDefinition(node): void { | ||
if ( | ||
node.accessibility === 'private' && | ||
node.key.type === AST_NODE_TYPES.Identifier | ||
) { | ||
processPrivateIdentifier(node.key); | ||
} | ||
}, | ||
|
||
// Post-process the class members and report any remaining members. Since | ||
// private members can only be accessed in the current class context, we | ||
// can safely assume that all usages are within the current class body. | ||
'ClassBody:exit'(): void { | ||
const unusedPrivateMembers = trackedClassMembers.shift(); | ||
if (unusedPrivateMembers === undefined) { | ||
return; | ||
} | ||
|
||
for (const [ | ||
classMemberName, | ||
{ declaredNode }, | ||
] of unusedPrivateMembers.entries()) { | ||
if (trackedClassMembersUsed.has(declaredNode)) { | ||
continue; | ||
} | ||
context.report({ | ||
loc: declaredNode.key.loc, | ||
node: declaredNode, | ||
messageId: 'unusedPrivateClassMember', | ||
data: { | ||
classMemberName: `#${classMemberName}`, | ||
}, | ||
}); | ||
} | ||
}, | ||
}; | ||
}, | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.