-
Notifications
You must be signed in to change notification settings - Fork 889
Added prefer-readonly rule with fixes #2896
Conversation
Deceptively simple... I look forward to contributors finding new and exciting test cases.
src/rules/preferReadonlyRule.ts
Outdated
this.handlePropertyDeclaration(node as ts.PropertyDeclaration); | ||
break; | ||
|
||
case ts.SyntaxKind.PropertyAccessExpression: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking only property access may be enough for the initial implemenation, but we should consider using the type checker later to find all references
consider using the type checker to find all references
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd really prefer not to make this a typed-only rule. Seems like an unnecessary restriction, especially given the performance concerns. Would you be ok with it not doing that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no other choice. You need to know if the property is assigned on an object of a (sub)type of the current class. Consider the following code:
class A {
private foo;
private bar;
private baz;
public copyTo(other: A) {
other.foo = this.foo; // this one needs to be recognized as assignment
}
public copy(other: {[key: string]: any} {
// both should not be handled as assignments to `A#bar` or `A#baz`
other.bar = this.bar;
other.baz = this.baz;
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's really interesting. I didn't think of the copyTo
, thanks.
src/rules/preferReadonlyRule.ts
Outdated
this.scope = parentScope; | ||
} | ||
|
||
private handleConstructor(node: ts.ConstructorDeclaration) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handle parameter properties here
src/rules/preferReadonlyRule.ts
Outdated
private handleConstructor(node: ts.ConstructorDeclaration) { | ||
this.scope.inConstructor = true; | ||
ts.forEachChild(node, this.visitNode); | ||
this.scope.inConstructor = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's not sufficient, functions inside the constructor cannot assign readonly properties
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) { | ||
if (parent !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parent will never be undefined here
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) { | ||
if (parent !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this function should really check if node.expression
is a subtype of the current class
src/rules/preferReadonlyRule.ts
Outdated
|
||
private readonly visitNode = (node: ts.Node) => { | ||
switch (node.kind) { | ||
case ts.SyntaxKind.ClassDeclaration: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about ClassExpression
?
src/rules/preferReadonlyRule.ts
Outdated
private handlePropertyDeclaration(node: ts.PropertyDeclaration) { | ||
if (this.scope !== undefined | ||
&& utils.isModfierFlagSet(node, ts.ModifierFlags.Private) | ||
&& !utils.isModfierFlagSet(node, ts.ModifierFlags.Readonly)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
needs special handling for static
members
src/rules/preferReadonlyRule.ts
Outdated
private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) { | ||
if (parent !== undefined) { | ||
switch (parent.kind) { | ||
case ts.SyntaxKind.BinaryExpression: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also needs to handle Postfix and PrefixUnaryExpression to handle this.foo--
and ++this.bar
As suggested: * This now uses the type checker to see if types match * Keeps a stackable state for whether the scope is in a constructor (to deal with nested functions) * Adds a few more corner cases, such as parameter declarations, --/++ expressions, and overlapping classes The variable scoping logic was bleeding pretty heavily into the walker, so I moved it into its own class. Much clearer separation of concerns now.
Also added a test for IIFEs used to set them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
first quick review of the latest changes
src/rules/preferReadonlyRule.ts
Outdated
|
||
case ts.SyntaxKind.PostfixUnaryExpression: | ||
case ts.SyntaxKind.PrefixUnaryExpression: | ||
this.handlePostfixOrPrefixUnaryExpression(node as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should be moved into handlePropertyAccessExpression
src/rules/preferReadonlyRule.ts
Outdated
|
||
case ts.SyntaxKind.ClassDeclaration: | ||
case ts.SyntaxKind.ClassExpression: | ||
this.handleClassDeclarationOrExpression(node as ts.ClassDeclaration | ts.ClassExpression); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor nit: ts.ClassLikeDeclaration
src/rules/preferReadonlyRule.ts
Outdated
return; | ||
} | ||
|
||
this.scope.addVariableModification(node.operand as ts.PropertyAccessExpression); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
needs to check if node.operator
is PlusPlusToken
or MinusMinusToken
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private complainOnNode(node: ParameterOrPropertyDeclaration) { | ||
const fix = Lint.Replacement.appendText(node.name.getStart(), "readonly "); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just a suggestion: you could replace this with Lint.Replacement.appendText(node.modifiers!.end, " readonly")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. Is there a reason you have a preference for that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the new modifier is automatically placed next to the others. you don't have to worry about comments, line breaks, etc.
src/rules/preferReadonlyRule.ts
Outdated
private readonly visitNode = (node: ts.Node) => { | ||
switch (node.kind) { | ||
case ts.SyntaxKind.ArrowFunction: | ||
case ts.SyntaxKind.FunctionExpression: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
better replace this with utils.isFunctionScopeBoundary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, how come? I'm not seeing any benefit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a few more kinds to check here: ClassDeclaration
, ClassExpression
, FunctionDeclaration
, GetAccessor
, SetAccessor
, MethodDeclaration
and maybe some more:
http://www.typescriptlang.org/play/#src=class%20Foo%20%7B%0A%20%20%20%20private%20readonly%20foo%3A%20number%3B%0A%0A%20%20%20%20constructor()%20%7B%0A%20%20%20%20%20%20%20%20this.foo%20%3D%202%3B%0A%20%20%20%20%20%20%20%20let%20self%20%3D%20this%3B%0A%20%20%20%20%20%20%20%20class%20Inner%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20prop%20%3D%20self.foo%2B%2B%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20let%20innerObj%20%3D%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20doStuff()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%2B%2Bself.foo%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%2C%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20get%20foo()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20return%20self.foo%2B%2B%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%2C%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20set%20foo(foo)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20self.foo%20%3D%20foo%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20function%20fn()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20self.foo%2B%2B%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D
} | ||
|
||
public exitNonConstructorScope() { | ||
if (this.constructorScopeDepth > DIRECTLY_INSIDE_CONSTRUCTOR) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (this.constructorScopeDepth !== OUTSIDE_CONSTRUCTOR)
--this.constructorScopeDepth;
} | ||
|
||
public addDeclaredVariable(node: ParameterOrPropertyDeclaration) { | ||
if (!utils.isModfierFlagSet(node, ts.ModifierFlags.Private) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'll release a new version of tsutils
today, so you can adjust this to the correct name
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to still use the misnamed isModfierFlagSet
?
} | ||
|
||
if (utils.isModfierFlagSet(node, ts.ModifierFlags.Static)) { | ||
this.privateModifiableStatics.set(node.name.getText(), node); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the rule should probably just ignore properties with computed names
also the current implementation would consider these to be equal:
private foo;
private 'foo';
private "foo";
If you ignore computed property names, you can simply use node.name.text
here
} | ||
|
||
for (const baseType of [type, ...type.getBaseTypes()]) { | ||
if (baseType.symbol !== undefined && baseType.symbol.name === parentType.symbol.name) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
out of curiosity, is comparing by name really the best thing we can do here?
That could lead to false positives
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I didn't find a better alternative - would welcome suggestions!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's probably ok for now.
There's the internal typescript function isTypeInstanceOf
in checker.ts
that does exactly what we need here. We should ask to expose this functionality.
export class ClassScope { | ||
private readonly privateModifiableMembers = new Map<string, ParameterOrPropertyDeclaration>(); | ||
private readonly privateModifiableStatics = new Map<string, ParameterOrPropertyDeclaration>(); | ||
private readonly variableModifications = new Map<string, VariableModifications>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could consider making two separate lists of assigned instance members and one for static members
* Split variableModifications into two Sets * Corrected computed property names * Corrected logic around constructor scope depths * Stopped recursing on nodes with declare keywords * ++/-- unary expressions only as modifiers
CircleCI is failing.
Also brought in tsutils@2.4.0. That should fix any caching shenanigans.
@ajafff mind taking another look here? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM with suggestions
description: "Requires that private variables are marked as `readonly` if they're never modified outside of the constructor.", | ||
descriptionDetails: Lint.Utils.dedent` | ||
If a private variable is only assigned to in the constructor, it should be declared as \`readonly\`. | ||
`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this rule could use a rationale
to explain why private members should be readonly. I can't come up with a good reason
} | ||
|
||
public addDeclaredVariable(node: ParameterOrPropertyDeclaration) { | ||
if (!utils.isModfierFlagSet(node, ts.ModifierFlags.Private) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to still use the misnamed isModfierFlagSet
?
} | ||
|
||
for (const baseType of [type, ...type.getBaseTypes()]) { | ||
if (baseType.symbol !== undefined && baseType.symbol.name === parentType.symbol.name) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's probably ok for now.
There's the internal typescript function isTypeInstanceOf
in checker.ts
that does exactly what we need here. We should ask to expose this functionality.
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private handleConstructor(node: ts.ConstructorDeclaration) { | ||
if (this.scope !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this.scope
should never be undefined, because a constructor can only occur inside of a class
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private handlePropertyDeclaration(node: ts.PropertyDeclaration) { | ||
if (this.scope !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same as above, this.scope
will not be undefined
src/rules/preferReadonlyRule.ts
Outdated
} | ||
|
||
private handleDeleteExpression(node: ts.PropertyAccessExpression) { | ||
if (this.scope !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could move this check to handlePropertyAccessExpression
, so you don't have to check again here and in the other methods
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mostly looks good
|
||
export type ParameterOrPropertyDeclaration = ts.ParameterDeclaration | ts.PropertyDeclaration; | ||
|
||
function typeIsOrHasBaseType(type: ts.Type, parentType: ts.Type) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this probably belongs in a more generic utils file? maybe src/language/typeUtils.ts
return false; | ||
} | ||
|
||
export class ClassScope { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know there's one bit of readonly
-specific logic here, but I'd slightly prefer moving this class to src/language/classScope.ts
I know I mentioned in another thread that it's cool to have multiple files for a rule implementation, but in this case I think we can reasonably avoid it.
In the AppVeyor build:
Unrelated? |
# Conflicts: # package.json # src/language/walker/blockScopeAwareRuleWalker.ts
src/language/classScope.ts
Outdated
@@ -0,0 +1,108 @@ | |||
/** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this file belongs here...
I know @adidahiya suggested moving it, but this is not reusable at all.
I'd prefer to include it in the rule's source file. It will only get you to a total of about 300 lines, which is still reasonable.
src/rules/preferReadonlyRule.ts
Outdated
optionsDescription: "Not configurable.", | ||
rationale: Lint.Utils.dedent` | ||
Marking never-modified variables as readonly helps enforce the code's intent of keeping them as never-modified. | ||
It can also help prevent accidental changes of members not meant to be changed.`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general I declare properties readonly
if I don't want them to be modified, regardless of the access modifier.
This rule forces me to declare all private properties as readonly
that are currently not modified. If later on I decide to modify such a property, I need to edit 2 locations in the file, which bloats the diff.
I can imagine my co-workers not making a change (or making a really awkward one instead), because they think the property is readonly
on purpose and needs to stay that way. I think this may cause more confusion than good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this may cause more confusion than good.
Funny, I've had the opposite problem: coworkers reassigning properties that were never meant to be reassigned.
Partially to alleviate that concern, I've added an only-inline-lambdas
option that stops checking everything except private member = () => {}
-style arrow functions.
# Conflicts: # src/language/rule/rule.ts # src/rules/noReferenceImportRule.ts
# Conflicts: # src/rules/completedDocsRule.ts
Ping @adidahiya ? |
lgtm -- CI status is not being reported, maybe because of the typescript@next test runner |
@adidahiya CI was successful, but did not report. That's weird. Maybe reopening the PR and/or manually starting CI helps. Besides this issue, there is still a TODO for you: #3490 (comment) |
PR checklist
Overview of change:
Adds a
prefer-readonly
rule for private member variables that are never re-assigned.Is there anything you'd like reviewers to focus on?
Am I missing any relevant test cases?
CHANGELOG.md entry:
[new-rule]
prefer-readonly