Skip to content

Commit acab0a9

Browse files
authored
fix(eslint-plugin): [prefer-readonly] autofixer doesn't add type to property that is mutated in the constructor (#10552)
* initial implementation * add tests * verify the about-to-be-added type annotation is in-scope * auto fix only literals that have been modified in the class constructor
1 parent a3a157c commit acab0a9

File tree

2 files changed

+1198
-2
lines changed

2 files changed

+1198
-2
lines changed

packages/eslint-plugin/src/rules/prefer-readonly.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,38 @@ export default createRule<Options, MessageIds>({
173173
};
174174
}
175175

176+
function getTypeAnnotationForViolatingNode(
177+
node: TSESTree.Node,
178+
type: ts.Type,
179+
initializerType: ts.Type,
180+
) {
181+
const annotation = checker.typeToString(type);
182+
183+
// verify the about-to-be-added type annotation is in-scope
184+
if (tsutils.isTypeFlagSet(initializerType, ts.TypeFlags.EnumLiteral)) {
185+
const scope = context.sourceCode.getScope(node);
186+
const variable = ASTUtils.findVariable(scope, annotation);
187+
188+
if (variable == null) {
189+
return null;
190+
}
191+
192+
const definition = variable.defs.find(def => def.isTypeDefinition);
193+
194+
if (definition == null) {
195+
return null;
196+
}
197+
198+
const definitionType = services.getTypeAtLocation(definition.node);
199+
200+
if (definitionType !== type) {
201+
return null;
202+
}
203+
}
204+
205+
return annotation;
206+
}
207+
176208
return {
177209
[`${functionScopeBoundaries}:exit`](
178210
node:
@@ -229,13 +261,62 @@ export default createRule<Options, MessageIds>({
229261
}
230262
})();
231263

264+
const typeAnnotation = (() => {
265+
if (esNode.type !== AST_NODE_TYPES.PropertyDefinition) {
266+
return null;
267+
}
268+
269+
if (esNode.typeAnnotation || !esNode.value) {
270+
return null;
271+
}
272+
273+
if (nameNode.type !== AST_NODE_TYPES.Identifier) {
274+
return null;
275+
}
276+
277+
const hasConstructorModifications =
278+
finalizedClassScope.memberHasConstructorModifications(
279+
nameNode.name,
280+
);
281+
282+
if (!hasConstructorModifications) {
283+
return null;
284+
}
285+
286+
const violatingType = services.getTypeAtLocation(esNode);
287+
const initializerType = services.getTypeAtLocation(esNode.value);
288+
289+
// if the RHS is a literal, its type would be narrowed, while the
290+
// type of the initializer (which isn't `readonly`) would be the
291+
// widened type
292+
if (initializerType === violatingType) {
293+
return null;
294+
}
295+
296+
if (!tsutils.isLiteralType(initializerType)) {
297+
return null;
298+
}
299+
300+
return getTypeAnnotationForViolatingNode(
301+
esNode,
302+
violatingType,
303+
initializerType,
304+
);
305+
})();
306+
232307
context.report({
233308
...reportNodeOrLoc,
234309
messageId: 'preferReadonly',
235310
data: {
236311
name: context.sourceCode.getText(nameNode),
237312
},
238-
fix: fixer => fixer.insertTextBefore(nameNode, 'readonly '),
313+
*fix(fixer) {
314+
yield fixer.insertTextBefore(nameNode, 'readonly ');
315+
316+
if (typeAnnotation) {
317+
yield fixer.insertTextAfter(nameNode, `: ${typeAnnotation}`);
318+
}
319+
},
239320
});
240321
}
241322
},
@@ -288,6 +369,8 @@ class ClassScope {
288369
private readonly classType: ts.Type;
289370
private constructorScopeDepth = OUTSIDE_CONSTRUCTOR;
290371
private readonly memberVariableModifications = new Set<string>();
372+
private readonly memberVariableWithConstructorModifications =
373+
new Set<string>();
291374
private readonly privateModifiableMembers = new Map<
292375
string,
293376
ParameterOrPropertyDeclaration
@@ -358,6 +441,7 @@ class ClassScope {
358441
relationOfModifierTypeToClass === TypeToClassRelation.Instance &&
359442
this.constructorScopeDepth === DIRECTLY_INSIDE_CONSTRUCTOR
360443
) {
444+
this.memberVariableWithConstructorModifications.add(node.name.text);
361445
return;
362446
}
363447

@@ -465,4 +549,8 @@ class ClassScope {
465549

466550
return TypeToClassRelation.Instance;
467551
}
552+
553+
public memberHasConstructorModifications(name: string) {
554+
return this.memberVariableWithConstructorModifications.has(name);
555+
}
468556
}

0 commit comments

Comments
 (0)