Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Added prefer-readonly rule with fixes #2896

Merged
merged 22 commits into from
Nov 28, 2017
Merged

Added prefer-readonly rule with fixes #2896

merged 22 commits into from
Nov 28, 2017

Conversation

JoshuaKGoldberg
Copy link
Contributor

@JoshuaKGoldberg JoshuaKGoldberg commented Jun 7, 2017

PR checklist

  • Addresses an existing issue: readonly variables #1628
  • New feature, bugfix, or enhancement
    • Includes tests
  • Documentation update

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

this.handlePropertyDeclaration(node as ts.PropertyDeclaration);
break;

case ts.SyntaxKind.PropertyAccessExpression:
Copy link
Contributor

@ajafff ajafff Jun 7, 2017

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

Copy link
Contributor Author

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?

Copy link
Contributor

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;
    }
}

Copy link
Contributor Author

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.

this.scope = parentScope;
}

private handleConstructor(node: ts.ConstructorDeclaration) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle parameter properties here

private handleConstructor(node: ts.ConstructorDeclaration) {
this.scope.inConstructor = true;
ts.forEachChild(node, this.visitNode);
this.scope.inConstructor = false;
Copy link
Contributor

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

}

private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) {
if (parent !== undefined) {
Copy link
Contributor

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

}

private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) {
if (parent !== undefined) {
Copy link
Contributor

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


private readonly visitNode = (node: ts.Node) => {
switch (node.kind) {
case ts.SyntaxKind.ClassDeclaration:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about ClassExpression?

private handlePropertyDeclaration(node: ts.PropertyDeclaration) {
if (this.scope !== undefined
&& utils.isModfierFlagSet(node, ts.ModifierFlags.Private)
&& !utils.isModfierFlagSet(node, ts.ModifierFlags.Readonly)) {
Copy link
Contributor

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

private handlePropertyAccessExpression(node: ts.PropertyAccessExpression, parent: ts.Node | undefined) {
if (parent !== undefined) {
switch (parent.kind) {
case ts.SyntaxKind.BinaryExpression:
Copy link
Contributor

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

Josh Goldberg added 3 commits June 9, 2017 02:13
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.
Copy link
Contributor

@ajafff ajafff left a 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


case ts.SyntaxKind.PostfixUnaryExpression:
case ts.SyntaxKind.PrefixUnaryExpression:
this.handlePostfixOrPrefixUnaryExpression(node as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression);
Copy link
Contributor

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


case ts.SyntaxKind.ClassDeclaration:
case ts.SyntaxKind.ClassExpression:
this.handleClassDeclarationOrExpression(node as ts.ClassDeclaration | ts.ClassExpression);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor nit: ts.ClassLikeDeclaration

return;
}

this.scope.addVariableModification(node.operand as ts.PropertyAccessExpression);
Copy link
Contributor

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

}

private complainOnNode(node: ParameterOrPropertyDeclaration) {
const fix = Lint.Replacement.appendText(node.name.getStart(), "readonly ");
Copy link
Contributor

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")

Copy link
Contributor Author

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?

Copy link
Contributor

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.

private readonly visitNode = (node: ts.Node) => {
switch (node.kind) {
case ts.SyntaxKind.ArrowFunction:
case ts.SyntaxKind.FunctionExpression:
Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

public exitNonConstructorScope() {
if (this.constructorScopeDepth > DIRECTLY_INSIDE_CONSTRUCTOR) {
Copy link
Contributor

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)
Copy link
Contributor

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

Copy link
Contributor

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);
Copy link
Contributor

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) {
Copy link
Contributor

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

Copy link
Contributor Author

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!

Copy link
Contributor

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>();
Copy link
Contributor

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

Josh Goldberg added 6 commits June 11, 2017 17:16
* 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.
@adidahiya
Copy link
Contributor

@ajafff mind taking another look here?

Copy link
Contributor

@ajafff ajafff left a 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\`.
`,
Copy link
Contributor

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)
Copy link
Contributor

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) {
Copy link
Contributor

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.

}

private handleConstructor(node: ts.ConstructorDeclaration) {
if (this.scope !== undefined) {
Copy link
Contributor

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

}

private handlePropertyDeclaration(node: ts.PropertyDeclaration) {
if (this.scope !== undefined) {
Copy link
Contributor

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

}

private handleDeleteExpression(node: ts.PropertyAccessExpression) {
if (this.scope !== undefined) {
Copy link
Contributor

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

Copy link
Contributor

@adidahiya adidahiya left a 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) {
Copy link
Contributor

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 {
Copy link
Contributor

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.

@JoshuaKGoldberg
Copy link
Contributor Author

JoshuaKGoldberg commented Jun 19, 2017

In the AppVeyor build:

src/linter.ts(199,18): error TS2540: Cannot assign to 'program' because it is a constant or a read-only property.

Unrelated?

# Conflicts:
#	package.json
#	src/language/walker/blockScopeAwareRuleWalker.ts
@@ -0,0 +1,108 @@
/**
Copy link
Contributor

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.

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.`,
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Josh Goldberg added 3 commits November 4, 2017 10:27
# Conflicts:
#	src/language/rule/rule.ts
#	src/rules/noReferenceImportRule.ts
# Conflicts:
#	src/rules/completedDocsRule.ts
@JoshuaKGoldberg
Copy link
Contributor Author

Ping @adidahiya ?

@adidahiya
Copy link
Contributor

lgtm -- CI status is not being reported, maybe because of the typescript@next test runner

@ajafff
Copy link
Contributor

ajafff commented Nov 28, 2017

@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)

@adidahiya adidahiya merged commit 1bd48b5 into palantir:master Nov 28, 2017
@JoshuaKGoldberg JoshuaKGoldberg deleted the prefer-readonly branch November 28, 2017 16:35
@adidahiya adidahiya mentioned this pull request Jan 12, 2018
HyphnKnight pushed a commit to HyphnKnight/tslint that referenced this pull request Apr 9, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants