Skip to content

fix(eslint-plugin): [prefer-readonly] autofixer doesn't add type to property that is mutated in the constructor #10552

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion packages/eslint-plugin/src/rules/prefer-readonly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,38 @@ export default createRule<Options, MessageIds>({
};
}

function getTypeAnnotationForViolatingNode(
node: TSESTree.Node,
type: ts.Type,
initializerType: ts.Type,
) {
const annotation = checker.typeToString(type);

// verify the about-to-be-added type annotation is in-scope
if (tsutils.isTypeFlagSet(initializerType, ts.TypeFlags.EnumLiteral)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I wrote about this on #1056 (comment), but on some occasions (as far as I know only enums), the rule won't be able to add a type annotation:

// foo.ts
enum Foo {
  Bar,
  Bazz,
}

export const bar = Foo.Bar;

// index.ts
import { bar } from './foo';

export class Test {
  private foo = bar;
}

I chose not to add a type annotation on these cases, but an alternative I think is also valid is to have this be a suggestion instead of an auto-fix.

Would be happy to to hear opinions on this.

Copy link
Member

Choose a reason for hiding this comment

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

I have a vague memory of this coming up once in a while... I think it's fine for now. We don't have any kind of shared/standard plumbing for importing constructs in fixes or suggestions.

const scope = context.sourceCode.getScope(node);
const variable = ASTUtils.findVariable(scope, annotation);

if (variable == null) {
return null;
}

const definition = variable.defs.find(def => def.isTypeDefinition);

if (definition == null) {
return null;
}

const definitionType = services.getTypeAtLocation(definition.node);

if (definitionType !== type) {
return null;
}
}

return annotation;
}

return {
[`${functionScopeBoundaries}:exit`](
node:
Expand Down Expand Up @@ -229,13 +261,62 @@ export default createRule<Options, MessageIds>({
}
})();

const typeAnnotation = (() => {
if (esNode.type !== AST_NODE_TYPES.PropertyDefinition) {
return null;
}

if (esNode.typeAnnotation || !esNode.value) {
return null;
}

if (nameNode.type !== AST_NODE_TYPES.Identifier) {
return null;
}
Comment on lines +273 to +275
Copy link
Member Author

Choose a reason for hiding this comment

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

To my understanding, this can realistically only be an identifier. This means some cases that should have a type annotation added won't receive it, for example:

class TestOverlappingClassVariable {
  private 'overlappingClassVariable' = 7;

  constructor() {
    this.overlappingClassVariable = 10;
  }
}

This seems like an existing bug that's not necessarily related to this addition. I've opened #10655 as a separate issue to address this.


const hasConstructorModifications =
finalizedClassScope.memberHasConstructorModifications(
nameNode.name,
);

if (!hasConstructorModifications) {
return null;
}

const violatingType = services.getTypeAtLocation(esNode);
const initializerType = services.getTypeAtLocation(esNode.value);

// if the RHS is a literal, its type would be narrowed, while the
// type of the initializer (which isn't `readonly`) would be the
// widened type
if (initializerType === violatingType) {
return null;
}

if (!tsutils.isLiteralType(initializerType)) {
return null;
}

return getTypeAnnotationForViolatingNode(
esNode,
violatingType,
initializerType,
);
})();

context.report({
...reportNodeOrLoc,
messageId: 'preferReadonly',
data: {
name: context.sourceCode.getText(nameNode),
},
fix: fixer => fixer.insertTextBefore(nameNode, 'readonly '),
*fix(fixer) {
yield fixer.insertTextBefore(nameNode, 'readonly ');

if (typeAnnotation) {
yield fixer.insertTextAfter(nameNode, `: ${typeAnnotation}`);
}
},
});
}
},
Expand Down Expand Up @@ -288,6 +369,8 @@ class ClassScope {
private readonly classType: ts.Type;
private constructorScopeDepth = OUTSIDE_CONSTRUCTOR;
private readonly memberVariableModifications = new Set<string>();
private readonly memberVariableWithConstructorModifications =
new Set<string>();
private readonly privateModifiableMembers = new Map<
string,
ParameterOrPropertyDeclaration
Expand Down Expand Up @@ -358,6 +441,7 @@ class ClassScope {
relationOfModifierTypeToClass === TypeToClassRelation.Instance &&
this.constructorScopeDepth === DIRECTLY_INSIDE_CONSTRUCTOR
) {
this.memberVariableWithConstructorModifications.add(node.name.text);
return;
}

Expand Down Expand Up @@ -465,4 +549,8 @@ class ClassScope {

return TypeToClassRelation.Instance;
}

public memberHasConstructorModifications(name: string) {
return this.memberVariableWithConstructorModifications.has(name);
}
}
Loading
Loading