From c600b39d2a1e1bc9652dabef54b030707b5cfc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Thu, 31 Jul 2025 16:11:15 +0200 Subject: [PATCH 01/22] feat: add avoid_missing_dispose lint --- packages/leancode_lint/lib/leancode_lint.dart | 2 + .../lib/lints/avoid_missing_dispose.dart | 253 ++++++++++++++++++ .../lib/avoid_missing_dispose_test.dart | 134 ++++++++++ 3 files changed, 389 insertions(+) create mode 100644 packages/leancode_lint/lib/lints/avoid_missing_dispose.dart create mode 100644 packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart diff --git a/packages/leancode_lint/lib/leancode_lint.dart b/packages/leancode_lint/lib/leancode_lint.dart index fc004f25c..143a88486 100644 --- a/packages/leancode_lint/lib/leancode_lint.dart +++ b/packages/leancode_lint/lib/leancode_lint.dart @@ -4,6 +4,7 @@ import 'package:leancode_lint/assists/convert_positional_to_named_formal.dart'; import 'package:leancode_lint/assists/convert_record_into_nominal_type.dart'; import 'package:leancode_lint/lints/add_cubit_suffix_for_cubits.dart'; import 'package:leancode_lint/lints/avoid_conditional_hooks.dart'; +import 'package:leancode_lint/lints/avoid_missing_dispose.dart'; import 'package:leancode_lint/lints/avoid_single_child_in_multi_child_widget.dart'; import 'package:leancode_lint/lints/catch_parameter_names.dart'; import 'package:leancode_lint/lints/constructor_parameters_and_fields_should_have_the_same_order.dart'; @@ -34,6 +35,7 @@ class _Linter extends PluginBase { const UsePadding(), const UseDedicatedMediaQueryMethods(), const PreferCenterOverAlign(), + AvoidMissingDispose(), ]; @override diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart new file mode 100644 index 000000000..0e49662a9 --- /dev/null +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -0,0 +1,253 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/element2.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; +import 'package:leancode_lint/utils.dart'; + +/// Checks for proper disposal of resources in StatefulWidget classes. +/// Warns when disposable resources are not properly disposed in the dispose() method. +class AvoidMissingDispose extends DartLintRule { + AvoidMissingDispose() : super(code: _createCode()); + + static LintCode _createCode() => const LintCode( + name: ruleName, + problemMessage: 'Resource should be disposed in the dispose() method.', + correctionMessage: 'Add disposal of this resource in the dispose() method.', + errorSeverity: ErrorSeverity.WARNING, + ); + + static const ruleName = 'avoid_missing_dispose'; + + @override + void run( + CustomLintResolver resolver, + ErrorReporter reporter, + CustomLintContext context, + ) { + context.registry.addFieldDeclaration((node) { + final classNode = _getContainingClass(node); + if (classNode == null) { + return; + } + + if (_isWidget(classNode) && !_isFieldUsedByConstructor(classNode, node)) { + reporter.atNode(node, _createCode()); + return; + } + + if (!_isStateOfWidget(classNode)) { + return; + } + + final type = _getFieldDeclarationType(node); + + if (type == null || !_isDisposable(type)) { + return; + } + + final disposeExpressions = _DisposeExpressionsGatherer.gatherForTarget( + node: classNode, + targetName: node.fields.variables.first.name.lexeme, + ); + + if (disposeExpressions.isEmpty) { + reporter.atNode(node, _createCode()); + } + }); + context.registry.addInstanceCreationExpression((node) { + final classNode = _getContainingClass(node); + + if (classNode == null || + !(_isWidget(classNode) || _isStateOfWidget(classNode))) { + return; + } + + final type = switch (node.staticType) { + final InterfaceType type => type, + _ => null, + }; + + if (type == null || !_isDisposable(type)) { + return; + } + + if (_isInReturnWidget(node)) { + reporter.atNode(node, _createCode()); + return; + } + }); + } + + InterfaceType? _getFieldDeclarationType(FieldDeclaration field) { + if (field.fields.type?.type case final InterfaceType type) { + return type; + } + if (field.fields.variables.first.initializer?.staticType + case final InterfaceType? type) { + return type; + } + return null; + } + + bool _isFieldUsedByConstructor( + ClassDeclaration classNode, + FieldDeclaration node, + ) { + final constructorDeclaration = _getConstructorDeclaration(classNode); + if (constructorDeclaration == null) { + return false; + } + + return _hasConstructorParameterOrInitializer( + constructorDeclaration, + node.fields.variables.first.name.lexeme, + ); + } + + bool _hasConstructorParameterOrInitializer( + ConstructorDeclaration constructorDeclaration, + String parameterName, + ) { + for (final parameter in constructorDeclaration.parameters.parameters) { + if (parameter.name?.lexeme == parameterName) { + return true; + } + } + for (final initializer in constructorDeclaration.initializers) { + if (initializer case final ConstructorFieldInitializer initializer) { + if (initializer.fieldName.name == parameterName) { + return true; + } + } + } + return false; + } + + ConstructorDeclaration? _getConstructorDeclaration( + ClassDeclaration classNode, + ) { + for (final member in classNode.members) { + if (member case final ConstructorDeclaration constructorDeclaration) { + return constructorDeclaration; + } + } + return null; + } + + ClassDeclaration? _getContainingClass(AstNode node) { + var classNode = node.parent; + while (classNode != null && classNode is! ClassDeclaration) { + classNode = classNode.parent; + } + if (classNode case final ClassDeclaration classNode) { + return classNode; + } + return null; + } + + bool _isDisposable(InterfaceType type) { + // Checks if current class has dispose method + var hasDispose = type.methods2.any((method) => method.name3 == 'dispose'); + // Checks if current class has dispose method in the inherited members + if (!hasDispose) { + hasDispose = + type.element3.inheritedMembers.entries.firstWhereOrNull( + (entry) => + entry.key.name == 'dispose' && + entry.value.baseElement is MethodElement2, + ) != + null; + } + return hasDispose; + } + + bool _isType(InterfaceType type, String typeName) { + if (type.element3.name3 == typeName) { + return true; + } + return type.element3.allSupertypes.any( + (supertype) => supertype.element3.name3 == typeName, + ); + } + + bool _isInReturnWidget(InstanceCreationExpression node) { + AstNode? currentNode = node; + var returnsWidget = false; + var isInReturn = false; + while (currentNode != null && !(returnsWidget && isInReturn)) { + if (!isInReturn && + (currentNode is ReturnStatement || + currentNode is ExpressionFunctionBody)) { + isInReturn = true; + } + if (currentNode is MethodDeclaration) { + if (currentNode.returnType case final NamedType returnType + when returnType.type is InterfaceType && + _isType(returnType.type! as InterfaceType, 'Widget')) { + returnsWidget = true; + } else { + return false; + } + } + currentNode = currentNode.parent; + } + return returnsWidget && isInReturn; + } + + bool _isStateOfWidget(ClassDeclaration classNode) { + // Suggested ClassElement2 doesn't work in this case + // ignore: deprecated_member_use + if (classNode.declaredFragment case final ClassElement element) { + return element.allSupertypes.any( + (type) => type.element3.name3 == 'State', + ); + } + return false; + } + + bool _isWidget(ClassDeclaration classNode) { + // Suggested ClassElement2 doesn't work in this case + // ignore: deprecated_member_use + if (classNode.declaredFragment case final ClassElement element) { + return element.allSupertypes.any( + (type) => type.element3.name3 == 'Widget', + ); + } + return false; + } +} + +class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { + _DisposeExpressionsGatherer({required this.targetName}); + final String targetName; + + final List _disposeExpressions = []; + + static List gatherForTarget({ + required AstNode node, + required String targetName, + }) { + final visitor = _DisposeExpressionsGatherer(targetName: targetName); + node.accept(visitor); + return visitor._disposeExpressions; + } + + static const disposeMethodName = 'dispose'; + + @override + void visitExpressionStatement(ExpressionStatement node) { + if (node.expression case final MethodInvocation methodInvocation) { + if (methodInvocation.methodName.name == disposeMethodName) { + if (methodInvocation.target case final SimpleIdentifier target) { + if (target.name == targetName) { + _disposeExpressions.add(methodInvocation); + } + } + } + } + } +} diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart new file mode 100644 index 000000000..9386f01ac --- /dev/null +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -0,0 +1,134 @@ +// ignore for lint test +// ignore_for_file: unused_field, prefer_final_fields, unused_element + +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class MissingDisposeStatefulWidget extends StatefulWidget { + MissingDisposeStatefulWidget({ + super.key, + required this.scrollController, + required FocusNode focusNode, + }) : _focusNode = focusNode; + // expect_lint: avoid_missing_dispose + final controller = TextEditingController(); + final ScrollController scrollController; + final FocusNode _focusNode; + + @override + State createState() => + _MissingDisposeStatefulWidgetState(); +} + +class _MissingDisposeStatefulWidgetState + extends State + with TickerProviderStateMixin { + late TextEditingController _textControllerTest; + late ScrollController _scrollController; + late FocusNode _focusNode; + late ValueNotifier _valueNotifier; + late final _focusNode2 = FocusNode(); + final _pageController = PageController(); + final _streamController = StreamController(); + late AnimationController _animationController; + + // expect_lint: avoid_missing_dispose + var _notDisposedController = ScrollController(); + // expect_lint: avoid_missing_dispose + late final _notDisposedController2 = FocusNode(); + // expect_lint: avoid_missing_dispose + final _notDisposedController3 = ValueNotifier(0); + // expect_lint: avoid_missing_dispose + late final ScrollController _notDisposedController4; + // expect_lint: avoid_missing_dispose + late final FocusNode _notDisposedController5; + // expect_lint: avoid_missing_dispose + late final ValueNotifier _notDisposedController6; + + @override + void dispose() { + void nestedDispose() { + if (Random().nextBool()) { + if (Random().nextBool()) { + switch (Random().nextInt(3)) { + case 0: + if (Random().nextBool()) { + _valueNotifier.dispose(); + } + _textControllerTest.dispose(); + case 1: + _scrollController.dispose(); + case 2: + _focusNode.dispose(); + } + } + } + } + + nestedDispose(); + _streamController.close(); + _focusNode.dispose(); + _focusNode2.dispose(); + _pageController.dispose(); + super.dispose(); + } + + void _disposeAnimationController() { + _animationController.dispose(); + } + + @override + Widget build(BuildContext context) { + final animationController2 = useAnimationController( + duration: const Duration(seconds: 1), + ); + return Column( + children: [ + // expect_lint: avoid_missing_dispose + TextField(controller: TextEditingController()), + _buildTextField(), + AnimatedBuilder( + animation: animationController2, + builder: (context, child) { + return Container( + color: Colors.red, + height: animationController2.value, + ); + }, + ), + ], + ); + } + + TextField _buildTextField() { + // expect_lint: avoid_missing_dispose + return TextField(controller: TextEditingController()); + } +} + +class StatelessMissingDisposeWidget extends StatelessWidget { + StatelessMissingDisposeWidget({ + super.key, + required this.scrollController, + required FocusNode focusNode, + }) : _focusNode = focusNode; + + // expect_lint: avoid_missing_dispose + final controller = TextEditingController(); + final ScrollController scrollController; + // Ignored because of testing lint rule purpose + // ignore: unused_field + final FocusNode _focusNode; + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } +} + +// Should be ignored: Regular class (not State) +class RegularClass { + final controller = TextEditingController(); +} From fcc23ae448febb2d752200f29dc88ca379afb9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Thu, 31 Jul 2025 19:21:45 +0200 Subject: [PATCH 02/22] feat: update test with missing dispose --- .../test/lints_test_app/lib/avoid_conditional_hooks_test.dart | 1 + .../test/lints_test_app/lib/avoid_missing_dispose_test.dart | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart index fbd511707..2edd7a0a0 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart @@ -71,6 +71,7 @@ class SampleConditionalExpressionHookWidget extends HookWidget { Random().nextBool() ? // expect_lint: avoid_conditional_hooks useTextEditingController() + // expect_lint: avoid_missing_dispose : TextEditingController(), ); } diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index 9386f01ac..e5b5e115b 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -119,7 +119,6 @@ class StatelessMissingDisposeWidget extends StatelessWidget { final controller = TextEditingController(); final ScrollController scrollController; // Ignored because of testing lint rule purpose - // ignore: unused_field final FocusNode _focusNode; @override From 2ccd6a0705bd5691c8459c8814e03590fcc2c3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Thu, 31 Jul 2025 21:06:17 +0200 Subject: [PATCH 03/22] feat: Add avoid missing dispose lint fix --- .../lib/lints/avoid_missing_dispose.dart | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 0e49662a9..7f2c4f8ee 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -22,6 +22,9 @@ class AvoidMissingDispose extends DartLintRule { static const ruleName = 'avoid_missing_dispose'; + @override + List getFixes() => [_AddDisposeMethod()]; + @override void run( CustomLintResolver resolver, @@ -55,7 +58,14 @@ class AvoidMissingDispose extends DartLintRule { ); if (disposeExpressions.isEmpty) { - reporter.atNode(node, _createCode()); + reporter.atNode( + node, + _createCode(), + data: _AvoidMissingDisposeAnalysisData( + instanceName: node.fields.variables.first.name.lexeme, + classNode: classNode, + ), + ); } }); context.registry.addInstanceCreationExpression((node) { @@ -251,3 +261,54 @@ class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { } } } + +class _AddDisposeMethod extends DartFix { + @override + void run( + CustomLintResolver resolver, + ChangeReporter reporter, + CustomLintContext context, + AnalysisError analysisError, + List others, + ) { + if (analysisError.data case final _AvoidMissingDisposeAnalysisData data) { + final disposeMethodNode = _getStateDisposeMethod(data.classNode); + if (disposeMethodNode?.body case final BlockFunctionBody body + when body.block.statements.isNotEmpty) { + reporter + .createChangeBuilder( + message: + 'Add ${data.instanceName}.dispose() to the state dispose method', + priority: 80, + ) + .addDartFileEdit((builder) { + builder.addSimpleInsertion( + body.block.statements.first.offset, + '${data.instanceName}.dispose();\n ', + ); + }); + } + } + } + + MethodDeclaration? _getStateDisposeMethod(ClassDeclaration classNode) { + for (final member in classNode.members) { + if (member case final MethodDeclaration methodDeclaration) { + if (methodDeclaration.name.lexeme == 'dispose') { + return methodDeclaration; + } + } + } + return null; + } +} + +class _AvoidMissingDisposeAnalysisData { + const _AvoidMissingDisposeAnalysisData({ + required this.instanceName, + required this.classNode, + }); + + final String instanceName; + final ClassDeclaration classNode; +} From d59f0904b45e5a194949f9f3c7247fb50676126c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 09:51:16 +0200 Subject: [PATCH 04/22] feat: add readme avoid missing dispose --- packages/leancode_lint/README.md | 63 +++++++++++++++++++ .../lib/lints/avoid_missing_dispose.dart | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index d34415584..dc295993f 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -469,6 +469,69 @@ class MyWidget extends StatelessWidget { None +### `avoid_missing_dispose` + +**DO** dispose of disposable resources in StatefulWidget State classes. + +Disposable resources like controllers, and focus nodes must be properly disposed in the `dispose()` method to prevent memory leaks. + +**BAD:** + +```dart +class MyWidgetState extends State { + final late TextEditingController controller = TextEditingController(); + final late FocusNode focusNode = FocusNode(); + + @override + Widget build(BuildContext context) { + return TextField(controller: controller, focusNode: focusNode); + } +} +``` + +**BAD:** + +```dart +class MyWidgetState extends State { + @override + Widget build(BuildContext context) { + return TextField(controller: TextEditingController()); + } +} +``` + +**GOOD:** + +```dart +class MyWidgetState extends State { + late TextEditingController controller; + late FocusNode focusNode; + + @override + void initState() { + super.initState(); + controller = TextEditingController(); + focusNode = FocusNode(); + } + + @override + Widget build(BuildContext context) { + return TextField(controller: controller, focusNode: focusNode); + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } +} +``` + +#### Configuration + +None. + ## Assists Assists are IDE refactorings not related to a particular issue. They can be triggered by placing your cursor over a relevant piece of code and opening the code actions dialog. For instance, in VSCode this is done with ctrl+. or +.. diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 7f2c4f8ee..aa08423bf 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -16,7 +16,7 @@ class AvoidMissingDispose extends DartLintRule { static LintCode _createCode() => const LintCode( name: ruleName, problemMessage: 'Resource should be disposed in the dispose() method.', - correctionMessage: 'Add disposal of this resource in the dispose() method.', + correctionMessage: 'Add disposal of this resource.', errorSeverity: ErrorSeverity.WARNING, ); From 9c6320c6d758631fb468a92fe7265a3380944357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 10:34:42 +0200 Subject: [PATCH 05/22] feat: add ignored_instances config to avoid missing dispose lint --- packages/leancode_lint/README.md | 11 +- packages/leancode_lint/lib/leancode_lint.dart | 2 +- .../lib/lints/avoid_missing_dispose.dart | 56 +++++++-- packages/leancode_lint/pubspec.yaml | 1 + .../test/lints_test_app/analysis_options.yaml | 3 + .../lib/avoid_missing_dispose_test.dart | 6 +- .../test/lints_test_app/pubspec.lock | 108 +++++++++--------- 7 files changed, 118 insertions(+), 69 deletions(-) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index dc295993f..ad071feec 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -530,7 +530,16 @@ class MyWidgetState extends State { #### Configuration -None. +```yaml +custom_lint: + rules: + - avoid_missing_dispose: + ignored_instances: + - TextEditingController # Skip TextEditingController instances + - FocusNode # Skip FocusNode instances +``` + +`ignored_instances` skips checking dispose for specified types. This allows you to disable the lint rule for certain classes where you don't want to enforce dispose method checks. ## Assists diff --git a/packages/leancode_lint/lib/leancode_lint.dart b/packages/leancode_lint/lib/leancode_lint.dart index 143a88486..af906d4ba 100644 --- a/packages/leancode_lint/lib/leancode_lint.dart +++ b/packages/leancode_lint/lib/leancode_lint.dart @@ -35,7 +35,7 @@ class _Linter extends PluginBase { const UsePadding(), const UseDedicatedMediaQueryMethods(), const PreferCenterOverAlign(), - AvoidMissingDispose(), + AvoidMissingDispose.fromConfigs(configs), ]; @override diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index aa08423bf..02246f2b7 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -7,11 +7,35 @@ import 'package:analyzer/error/error.dart' hide LintCode; import 'package:analyzer/error/listener.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:leancode_lint/utils.dart'; +import 'package:yaml/yaml.dart'; + +class AvoidMissingDisposeConfig { + const AvoidMissingDisposeConfig({this.ignoredInstances = const {}}); + + factory AvoidMissingDisposeConfig.fromConfig(Map json) { + return AvoidMissingDisposeConfig( + ignoredInstances: + (json['ignored_instances'] as YamlList?) + ?.map((e) => e.toString()) + .toSet() ?? + {}, + ); + } + + final Set ignoredInstances; +} /// Checks for proper disposal of resources in StatefulWidget classes. /// Warns when disposable resources are not properly disposed in the dispose() method. class AvoidMissingDispose extends DartLintRule { - AvoidMissingDispose() : super(code: _createCode()); + AvoidMissingDispose({required this.config}) : super(code: _createCode()); + + AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) + : this( + config: AvoidMissingDisposeConfig.fromConfig( + configs.rules[ruleName]?.json ?? {}, + ), + ); static LintCode _createCode() => const LintCode( name: ruleName, @@ -20,6 +44,8 @@ class AvoidMissingDispose extends DartLintRule { errorSeverity: ErrorSeverity.WARNING, ); + final AvoidMissingDisposeConfig config; + static const ruleName = 'avoid_missing_dispose'; @override @@ -32,6 +58,13 @@ class AvoidMissingDispose extends DartLintRule { CustomLintContext context, ) { context.registry.addFieldDeclaration((node) { + final type = _getFieldDeclarationType(node); + + if (type == null || + config.ignoredInstances.contains(type.element3.name3)) { + return; + } + final classNode = _getContainingClass(node); if (classNode == null) { return; @@ -46,9 +79,7 @@ class AvoidMissingDispose extends DartLintRule { return; } - final type = _getFieldDeclarationType(node); - - if (type == null || !_isDisposable(type)) { + if (!_isDisposable(type)) { return; } @@ -69,6 +100,16 @@ class AvoidMissingDispose extends DartLintRule { } }); context.registry.addInstanceCreationExpression((node) { + final type = switch (node.staticType) { + final InterfaceType type => type, + _ => null, + }; + + if (type == null || + config.ignoredInstances.contains(type.element3.name3)) { + return; + } + final classNode = _getContainingClass(node); if (classNode == null || @@ -76,12 +117,7 @@ class AvoidMissingDispose extends DartLintRule { return; } - final type = switch (node.staticType) { - final InterfaceType type => type, - _ => null, - }; - - if (type == null || !_isDisposable(type)) { + if (!_isDisposable(type)) { return; } diff --git a/packages/leancode_lint/pubspec.yaml b/packages/leancode_lint/pubspec.yaml index 77aead11e..83f4e4a38 100644 --- a/packages/leancode_lint/pubspec.yaml +++ b/packages/leancode_lint/pubspec.yaml @@ -16,3 +16,4 @@ dependencies: analyzer_plugin: ^0.13.0 collection: ^1.19.1 custom_lint_builder: ^0.7.5 + yaml: ^3.1.3 diff --git a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml index ff0317ee1..0f2459078 100644 --- a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml +++ b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml @@ -18,6 +18,9 @@ custom_lint: application_prefix: Lncd # TODO: remove explicit enable once enabled by default - constructor_parameters_and_fields_should_have_the_same_order: true + - avoid_missing_dispose: + ignored_instances: + - AnimationController analyzer: plugins: diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index e5b5e115b..a85fd8751 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -1,4 +1,4 @@ -// ignore for lint test +// ignored for lint test purpose // ignore_for_file: unused_field, prefer_final_fields, unused_element import 'dart:async'; @@ -47,6 +47,8 @@ class _MissingDisposeStatefulWidgetState // expect_lint: avoid_missing_dispose late final ValueNotifier _notDisposedController6; + late final AnimationController _ignoredInstance; + @override void dispose() { void nestedDispose() { @@ -118,7 +120,6 @@ class StatelessMissingDisposeWidget extends StatelessWidget { // expect_lint: avoid_missing_dispose final controller = TextEditingController(); final ScrollController scrollController; - // Ignored because of testing lint rule purpose final FocusNode _focusNode; @override @@ -127,7 +128,6 @@ class StatelessMissingDisposeWidget extends StatelessWidget { } } -// Should be ignored: Regular class (not State) class RegularClass { final controller = TextEditingController(); } diff --git a/packages/leancode_lint/test/lints_test_app/pubspec.lock b/packages/leancode_lint/test/lints_test_app/pubspec.lock index 20bd5b57d..126e2e343 100644 --- a/packages/leancode_lint/test/lints_test_app/pubspec.lock +++ b/packages/leancode_lint/test/lints_test_app/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "80.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.6.0" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4 + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.13.4" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" bloc: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" ci: dependency: transitive description: @@ -117,18 +117,18 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: "409c485fd14f544af1da965d5a0d160ee57cd58b63eeaa7280a4f28cf5bda7f1" + sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84" url: "https://pub.dev" source: hosted - version: "0.7.5" + version: "0.7.6" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: "107e0a43606138015777590ee8ce32f26ba7415c25b722ff0908a6f5d7a4c228" + sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414" url: "https://pub.dev" source: hosted - version: "0.7.5" + version: "0.7.6" custom_lint_core: dependency: transitive description: @@ -141,18 +141,18 @@ packages: dependency: transitive description: name: custom_lint_visitor - sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" url: "https://pub.dev" source: hosted - version: "1.0.0+7.3.0" + version: "1.0.0+7.7.0" dart_style: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" file: dependency: transitive description: @@ -202,18 +202,18 @@ packages: dependency: transitive description: name: freezed_annotation - sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.0" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" hooks_riverpod: dependency: "direct main" description: @@ -226,10 +226,10 @@ packages: dependency: transitive description: name: hotreloader - sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" json_annotation: dependency: transitive description: @@ -244,7 +244,7 @@ packages: path: "../.." relative: true source: path - version: "18.0.0" + version: "17.0.0" logging: dependency: transitive description: @@ -289,10 +289,10 @@ packages: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" path: dependency: transitive description: @@ -305,18 +305,18 @@ packages: dependency: transitive description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" pubspec_parse: dependency: transitive description: @@ -374,10 +374,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" state_notifier: dependency: transitive description: @@ -390,42 +390,42 @@ packages: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" typed_data: dependency: transitive description: @@ -446,34 +446,34 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.21.0-13.0.pre.4" From 554d33b50f0dea79c9a6a9d353eeb29d165fa5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 11:57:44 +0200 Subject: [PATCH 06/22] chore: pr fixes --- packages/leancode_lint/README.md | 8 +- .../lib/lints/avoid_missing_dispose.dart | 76 ++++++++++--------- .../lib/avoid_conditional_hooks_test.dart | 3 +- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index ad071feec..f69fb9bfe 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -479,8 +479,8 @@ Disposable resources like controllers, and focus nodes must be properly disposed ```dart class MyWidgetState extends State { - final late TextEditingController controller = TextEditingController(); - final late FocusNode focusNode = FocusNode(); + late final TextEditingController controller = TextEditingController(); + late final FocusNode focusNode = FocusNode(); @override Widget build(BuildContext context) { @@ -504,8 +504,8 @@ class MyWidgetState extends State { ```dart class MyWidgetState extends State { - late TextEditingController controller; - late FocusNode focusNode; + late final TextEditingController controller; + late final FocusNode focusNode; @override void initState() { diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 02246f2b7..caea03d14 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -18,7 +18,7 @@ class AvoidMissingDisposeConfig { (json['ignored_instances'] as YamlList?) ?.map((e) => e.toString()) .toSet() ?? - {}, + const {}, ); } @@ -28,7 +28,16 @@ class AvoidMissingDisposeConfig { /// Checks for proper disposal of resources in StatefulWidget classes. /// Warns when disposable resources are not properly disposed in the dispose() method. class AvoidMissingDispose extends DartLintRule { - AvoidMissingDispose({required this.config}) : super(code: _createCode()); + const AvoidMissingDispose({required this.config}) + : super( + code: const LintCode( + name: ruleName, + problemMessage: + 'Resource should be disposed in the dispose() method.', + correctionMessage: 'Add disposal of this resource.', + errorSeverity: ErrorSeverity.WARNING, + ), + ); AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) : this( @@ -37,13 +46,6 @@ class AvoidMissingDispose extends DartLintRule { ), ); - static LintCode _createCode() => const LintCode( - name: ruleName, - problemMessage: 'Resource should be disposed in the dispose() method.', - correctionMessage: 'Add disposal of this resource.', - errorSeverity: ErrorSeverity.WARNING, - ); - final AvoidMissingDisposeConfig config; static const ruleName = 'avoid_missing_dispose'; @@ -71,7 +73,7 @@ class AvoidMissingDispose extends DartLintRule { } if (_isWidget(classNode) && !_isFieldUsedByConstructor(classNode, node)) { - reporter.atNode(node, _createCode()); + reporter.atNode(node, code); return; } @@ -91,7 +93,7 @@ class AvoidMissingDispose extends DartLintRule { if (disposeExpressions.isEmpty) { reporter.atNode( node, - _createCode(), + code, data: _AvoidMissingDisposeAnalysisData( instanceName: node.fields.variables.first.name.lexeme, classNode: classNode, @@ -122,7 +124,7 @@ class AvoidMissingDispose extends DartLintRule { } if (_isInReturnWidget(node)) { - reporter.atNode(node, _createCode()); + reporter.atNode(node, code); return; } }); @@ -164,10 +166,9 @@ class AvoidMissingDispose extends DartLintRule { } } for (final initializer in constructorDeclaration.initializers) { - if (initializer case final ConstructorFieldInitializer initializer) { - if (initializer.fieldName.name == parameterName) { - return true; - } + if (initializer case final ConstructorFieldInitializer initializer + when initializer.fieldName.name == parameterName) { + return true; } } return false; @@ -176,12 +177,13 @@ class AvoidMissingDispose extends DartLintRule { ConstructorDeclaration? _getConstructorDeclaration( ClassDeclaration classNode, ) { - for (final member in classNode.members) { - if (member case final ConstructorDeclaration constructorDeclaration) { - return constructorDeclaration; - } - } - return null; + return classNode.members.firstWhereOrNull( + (member) => switch (member) { + final ConstructorDeclaration _ => true, + _ => false, + }, + ) + as ConstructorDeclaration?; } ClassDeclaration? _getContainingClass(AstNode node) { @@ -269,6 +271,7 @@ class AvoidMissingDispose extends DartLintRule { class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { _DisposeExpressionsGatherer({required this.targetName}); + final String targetName; final List _disposeExpressions = []; @@ -286,13 +289,11 @@ class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { @override void visitExpressionStatement(ExpressionStatement node) { - if (node.expression case final MethodInvocation methodInvocation) { - if (methodInvocation.methodName.name == disposeMethodName) { - if (methodInvocation.target case final SimpleIdentifier target) { - if (target.name == targetName) { - _disposeExpressions.add(methodInvocation); - } - } + if (node.expression case final MethodInvocation methodInvocation + when methodInvocation.methodName.name == disposeMethodName) { + if (methodInvocation.target case final SimpleIdentifier target + when target.name == targetName) { + _disposeExpressions.add(methodInvocation); } } } @@ -328,14 +329,15 @@ class _AddDisposeMethod extends DartFix { } MethodDeclaration? _getStateDisposeMethod(ClassDeclaration classNode) { - for (final member in classNode.members) { - if (member case final MethodDeclaration methodDeclaration) { - if (methodDeclaration.name.lexeme == 'dispose') { - return methodDeclaration; - } - } - } - return null; + return classNode.members.firstWhereOrNull( + (member) => switch (member) { + final MethodDeclaration methodDeclaration + when methodDeclaration.name.lexeme == 'dispose' => + true, + _ => false, + }, + ) + as MethodDeclaration?; } } diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart index 2edd7a0a0..e63e3772c 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart @@ -71,7 +71,8 @@ class SampleConditionalExpressionHookWidget extends HookWidget { Random().nextBool() ? // expect_lint: avoid_conditional_hooks useTextEditingController() - // expect_lint: avoid_missing_dispose + // Not related to the test + // ignore: avoid_missing_dispose : TextEditingController(), ); } From 3a8f5736c47c0041b71fd5d6913050c5d2637060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 12:03:47 +0200 Subject: [PATCH 07/22] chore: pr fixes --- .../lib/avoid_missing_dispose_test.dart | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index a85fd8751..7dbc71a28 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -52,20 +52,16 @@ class _MissingDisposeStatefulWidgetState @override void dispose() { void nestedDispose() { - if (Random().nextBool()) { - if (Random().nextBool()) { - switch (Random().nextInt(3)) { - case 0: - if (Random().nextBool()) { - _valueNotifier.dispose(); - } - _textControllerTest.dispose(); - case 1: - _scrollController.dispose(); - case 2: - _focusNode.dispose(); + switch (0) { + case 0: + if (Random().nextBool()) { + _valueNotifier.dispose(); } - } + _textControllerTest.dispose(); + case 1: + _scrollController.dispose(); + case 2: + _focusNode.dispose(); } } From b1f39f3de488be2d24acc0de9d95ae7a2ada154c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 13:41:17 +0200 Subject: [PATCH 08/22] feat: remove nestedDispose example --- .../lib/avoid_missing_dispose_test.dart | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index 7dbc71a28..dab003578 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -51,21 +51,10 @@ class _MissingDisposeStatefulWidgetState @override void dispose() { - void nestedDispose() { - switch (0) { - case 0: - if (Random().nextBool()) { - _valueNotifier.dispose(); - } - _textControllerTest.dispose(); - case 1: - _scrollController.dispose(); - case 2: - _focusNode.dispose(); - } - } - - nestedDispose(); + _textControllerTest.dispose(); + _scrollController.dispose(); + _valueNotifier.dispose(); + _ignoredInstance.dispose(); _streamController.close(); _focusNode.dispose(); _focusNode2.dispose(); From fd57f0317738babc7a281e2244e7c9d92b882550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:30:42 +0200 Subject: [PATCH 09/22] feat: add ignored instance from package --- packages/leancode_lint/README.md | 10 +- .../lib/lints/avoid_missing_dispose.dart | 104 +++++++++++------- .../test/lints_test_app/analysis_options.yaml | 3 +- .../lib/avoid_missing_dispose_test.dart | 4 +- 4 files changed, 72 insertions(+), 49 deletions(-) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index f69fb9bfe..e9c0d11ec 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -534,12 +534,14 @@ class MyWidgetState extends State { custom_lint: rules: - avoid_missing_dispose: - ignored_instances: - - TextEditingController # Skip TextEditingController instances - - FocusNode # Skip FocusNode instances + ignored_instances: + - ignore: AnimationController + from_package: flutter ``` -`ignored_instances` skips checking dispose for specified types. This allows you to disable the lint rule for certain classes where you don't want to enforce dispose method checks. +- `ignored_instances` - an optional YamlList - skips dispose checks for specified types. This allows disabling the lint rule for classes where dispose method checks are not needed. + - `ignore` - A required String - name of the instance to ignore + - `from_package` - A required String - name of the source package ## Assists diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index caea03d14..869d7830f 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -9,20 +9,44 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:leancode_lint/utils.dart'; import 'package:yaml/yaml.dart'; +class _IgnoredInstance { + const _IgnoredInstance({required this.name, required this.packageName}); + + final String name; + final String packageName; +} + class AvoidMissingDisposeConfig { - const AvoidMissingDisposeConfig({this.ignoredInstances = const {}}); + const AvoidMissingDisposeConfig({ + this.ignoredInstances = const {}, + this.ignoredInstancesCheckers = const [], + }); factory AvoidMissingDisposeConfig.fromConfig(Map json) { + final ignoredInstances = + (json['ignored_instances'] as YamlList?)?.nodes + .map( + (e) => _IgnoredInstance( + name: (e as YamlMap)['ignore'] as String, + packageName: e['from_package'] as String, + ), + ) + .toSet() ?? + const {}; return AvoidMissingDisposeConfig( - ignoredInstances: - (json['ignored_instances'] as YamlList?) - ?.map((e) => e.toString()) - .toSet() ?? - const {}, + ignoredInstances: ignoredInstances, + ignoredInstancesCheckers: [ + for (final _IgnoredInstance(:name, :packageName) in ignoredInstances) + if (packageName.startsWith('dart:')) + TypeChecker.fromUrl('$packageName#$name') + else + TypeChecker.fromName(name, packageName: packageName), + ], ); } - final Set ignoredInstances; + final Set<_IgnoredInstance> ignoredInstances; + final List ignoredInstancesCheckers; } /// Checks for proper disposal of resources in StatefulWidget classes. @@ -39,12 +63,13 @@ class AvoidMissingDispose extends DartLintRule { ), ); - AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) - : this( - config: AvoidMissingDisposeConfig.fromConfig( - configs.rules[ruleName]?.json ?? {}, - ), - ); + factory AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) { + final config = AvoidMissingDisposeConfig.fromConfig( + configs.rules[ruleName]?.json ?? {}, + ); + + return AvoidMissingDispose(config: config); + } final AvoidMissingDisposeConfig config; @@ -62,8 +87,7 @@ class AvoidMissingDispose extends DartLintRule { context.registry.addFieldDeclaration((node) { final type = _getFieldDeclarationType(node); - if (type == null || - config.ignoredInstances.contains(type.element3.name3)) { + if (type == null || _isIgnoredInstance(type)) { return; } @@ -77,11 +101,7 @@ class AvoidMissingDispose extends DartLintRule { return; } - if (!_isStateOfWidget(classNode)) { - return; - } - - if (!_isDisposable(type)) { + if (!_isStateOfWidget(classNode) || !_isDisposable(type)) { return; } @@ -107,19 +127,15 @@ class AvoidMissingDispose extends DartLintRule { _ => null, }; - if (type == null || - config.ignoredInstances.contains(type.element3.name3)) { + if (type == null || _isIgnoredInstance(type)) { return; } final classNode = _getContainingClass(node); if (classNode == null || - !(_isWidget(classNode) || _isStateOfWidget(classNode))) { - return; - } - - if (!_isDisposable(type)) { + !(_isWidget(classNode) || _isStateOfWidget(classNode)) || + !_isDisposable(type)) { return; } @@ -130,6 +146,14 @@ class AvoidMissingDispose extends DartLintRule { }); } + bool _isIgnoredInstance(InterfaceType type) { + return config.ignoredInstancesCheckers.any( + // Remove this once analyzer is updated + // ignore: deprecated_member_use + (checker) => checker.isExactly(type.element), + ); + } + InterfaceType? _getFieldDeclarationType(FieldDeclaration field) { if (field.fields.type?.type case final InterfaceType type) { return type; @@ -232,15 +256,12 @@ class AvoidMissingDispose extends DartLintRule { currentNode is ExpressionFunctionBody)) { isInReturn = true; } - if (currentNode is MethodDeclaration) { - if (currentNode.returnType case final NamedType returnType - when returnType.type is InterfaceType && - _isType(returnType.type! as InterfaceType, 'Widget')) { - returnsWidget = true; - } else { - return false; - } + if (currentNode case MethodDeclaration( + returnType: NamedType(type: final InterfaceType type), + ) when _isType(type, 'Widget')) { + returnsWidget = true; } + currentNode = currentNode.parent; } return returnsWidget && isInReturn; @@ -289,12 +310,13 @@ class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { @override void visitExpressionStatement(ExpressionStatement node) { - if (node.expression case final MethodInvocation methodInvocation - when methodInvocation.methodName.name == disposeMethodName) { - if (methodInvocation.target case final SimpleIdentifier target - when target.name == targetName) { - _disposeExpressions.add(methodInvocation); - } + if (node.expression + case MethodInvocation( + methodName: SimpleIdentifier(name: disposeMethodName), + target: SimpleIdentifier(:final name), + ) && + final invocation when name == targetName) { + _disposeExpressions.add(invocation); } } } diff --git a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml index 0f2459078..0190f3e15 100644 --- a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml +++ b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml @@ -20,7 +20,8 @@ custom_lint: - constructor_parameters_and_fields_should_have_the_same_order: true - avoid_missing_dispose: ignored_instances: - - AnimationController + - ignore: AnimationController + from_package: flutter analyzer: plugins: diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index dab003578..5fb1ba875 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -2,7 +2,6 @@ // ignore_for_file: unused_field, prefer_final_fields, unused_element import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -46,7 +45,7 @@ class _MissingDisposeStatefulWidgetState late final FocusNode _notDisposedController5; // expect_lint: avoid_missing_dispose late final ValueNotifier _notDisposedController6; - + late final AnimationController _ignoredInstance; @override @@ -54,7 +53,6 @@ class _MissingDisposeStatefulWidgetState _textControllerTest.dispose(); _scrollController.dispose(); _valueNotifier.dispose(); - _ignoredInstance.dispose(); _streamController.close(); _focusNode.dispose(); _focusNode2.dispose(); From c89f6dc277f8d85cd0204df2cf4fa3b527d79b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:33:00 +0200 Subject: [PATCH 10/22] refactor: add dispose ignore for file --- .../test/lints_test_app/lib/avoid_conditional_hooks_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart index e63e3772c..57b5078ce 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart @@ -1,3 +1,5 @@ +// Not related to the test +// ignore_for_file: avoid_missing_dispose import 'dart:math'; import 'package:flutter/material.dart'; @@ -71,8 +73,6 @@ class SampleConditionalExpressionHookWidget extends HookWidget { Random().nextBool() ? // expect_lint: avoid_conditional_hooks useTextEditingController() - // Not related to the test - // ignore: avoid_missing_dispose : TextEditingController(), ); } From eb50a432583446d1e00842e8869d4027f08a6148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:35:55 +0200 Subject: [PATCH 11/22] refactor: adding where type hint from CR --- .../lib/lints/avoid_missing_dispose.dart | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 869d7830f..ff2d2547c 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -351,15 +351,9 @@ class _AddDisposeMethod extends DartFix { } MethodDeclaration? _getStateDisposeMethod(ClassDeclaration classNode) { - return classNode.members.firstWhereOrNull( - (member) => switch (member) { - final MethodDeclaration methodDeclaration - when methodDeclaration.name.lexeme == 'dispose' => - true, - _ => false, - }, - ) - as MethodDeclaration?; + return classNode.members.whereType().firstWhereOrNull( + (member) => member.name.lexeme == 'dispose', + ); } } From 0f2d6baf6cd5d1f4dbf1d6e4103f2597f32affa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:38:57 +0200 Subject: [PATCH 12/22] refactor: pattern matching DartFix class --- .../leancode_lint/lib/lints/avoid_missing_dispose.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index ff2d2547c..135c5b083 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -332,8 +332,9 @@ class _AddDisposeMethod extends DartFix { ) { if (analysisError.data case final _AvoidMissingDisposeAnalysisData data) { final disposeMethodNode = _getStateDisposeMethod(data.classNode); - if (disposeMethodNode?.body case final BlockFunctionBody body - when body.block.statements.isNotEmpty) { + if (disposeMethodNode?.body case BlockFunctionBody( + block: Block(statements: [final statement, ...]), + )) { reporter .createChangeBuilder( message: @@ -342,7 +343,7 @@ class _AddDisposeMethod extends DartFix { ) .addDartFileEdit((builder) { builder.addSimpleInsertion( - body.block.statements.first.offset, + statement.offset, '${data.instanceName}.dispose();\n ', ); }); From 62c8af1ac5edd02f6a8e7a207b858246973ad95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:40:31 +0200 Subject: [PATCH 13/22] refactor: pattern matching DartFix class --- .../lib/lints/avoid_missing_dispose.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 135c5b083..976708d87 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -330,21 +330,24 @@ class _AddDisposeMethod extends DartFix { AnalysisError analysisError, List others, ) { - if (analysisError.data case final _AvoidMissingDisposeAnalysisData data) { - final disposeMethodNode = _getStateDisposeMethod(data.classNode); + if (analysisError.data case _AvoidMissingDisposeAnalysisData( + classNode: final classNode, + instanceName: final instanceName, + )) { + final disposeMethodNode = _getStateDisposeMethod(classNode); if (disposeMethodNode?.body case BlockFunctionBody( block: Block(statements: [final statement, ...]), )) { reporter .createChangeBuilder( message: - 'Add ${data.instanceName}.dispose() to the state dispose method', + 'Add $instanceName.dispose() to the state dispose method', priority: 80, ) .addDartFileEdit((builder) { builder.addSimpleInsertion( statement.offset, - '${data.instanceName}.dispose();\n ', + '$instanceName.dispose();\n ', ); }); } From fd08a41a2f84562ea56b6d3386d60a595568d3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 15:55:41 +0200 Subject: [PATCH 14/22] refactor: CR hint --- .../lib/lints/avoid_missing_dispose.dart | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 976708d87..cd6459703 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -184,18 +184,15 @@ class AvoidMissingDispose extends DartLintRule { ConstructorDeclaration constructorDeclaration, String parameterName, ) { - for (final parameter in constructorDeclaration.parameters.parameters) { - if (parameter.name?.lexeme == parameterName) { - return true; - } - } - for (final initializer in constructorDeclaration.initializers) { - if (initializer case final ConstructorFieldInitializer initializer - when initializer.fieldName.name == parameterName) { - return true; - } + if (constructorDeclaration.parameters.parameters.any( + (parameter) => parameter.name?.lexeme == parameterName, + )) { + return true; } - return false; + + return constructorDeclaration.initializers + .whereType() + .any((initializer) => initializer.fieldName.name == parameterName); } ConstructorDeclaration? _getConstructorDeclaration( From f4309dce94d759ed45e05ec865f82f267c26adbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 16:08:15 +0200 Subject: [PATCH 15/22] refactor: CR hint with typeChecker --- .../lib/lints/avoid_missing_dispose.dart | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index cd6459703..8493518d3 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -219,28 +219,22 @@ class AvoidMissingDispose extends DartLintRule { } bool _isDisposable(InterfaceType type) { - // Checks if current class has dispose method - var hasDispose = type.methods2.any((method) => method.name3 == 'dispose'); - // Checks if current class has dispose method in the inherited members - if (!hasDispose) { - hasDispose = - type.element3.inheritedMembers.entries.firstWhereOrNull( - (entry) => - entry.key.name == 'dispose' && - entry.value.baseElement is MethodElement2, - ) != - null; - } - return hasDispose; + return type.methods2.any((method) => method.name3 == 'dispose') || + type.element3.inheritedMembers.entries.any( + (entry) => + entry.key.name == 'dispose' && + entry.value.baseElement is MethodElement2, + ); } - bool _isType(InterfaceType type, String typeName) { - if (type.element3.name3 == typeName) { - return true; - } - return type.element3.allSupertypes.any( - (supertype) => supertype.element3.name3 == typeName, + bool _isWidgetType(InterfaceType type) { + const widgetTypeChecker = TypeChecker.fromName( + 'Widget', + packageName: 'flutter', ); + + return widgetTypeChecker.isExactlyType(type) || + widgetTypeChecker.isSuperTypeOf(type); } bool _isInReturnWidget(InstanceCreationExpression node) { @@ -255,7 +249,7 @@ class AvoidMissingDispose extends DartLintRule { } if (currentNode case MethodDeclaration( returnType: NamedType(type: final InterfaceType type), - ) when _isType(type, 'Widget')) { + ) when _isWidgetType(type)) { returnsWidget = true; } From 9f54966d381945cbbf54efeed771821802619544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 1 Aug 2025 16:34:38 +0200 Subject: [PATCH 16/22] feat: used type checker instead manually checking if class is a widget --- .../lib/lints/avoid_missing_dispose.dart | 46 ++++++++++--------- .../lib/avoid_missing_dispose_test.dart | 2 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 8493518d3..79dbf0e74 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -1,6 +1,8 @@ +// Replace this once analyzer is updated +// ignore_for_file: deprecated_member_use + import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; -import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/element2.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/error/error.dart' hide LintCode; @@ -96,7 +98,8 @@ class AvoidMissingDispose extends DartLintRule { return; } - if (_isWidget(classNode) && !_isFieldUsedByConstructor(classNode, node)) { + if (_isWidgetClass(classNode) && + !_isFieldUsedByConstructor(classNode, node)) { reporter.atNode(node, code); return; } @@ -134,7 +137,7 @@ class AvoidMissingDispose extends DartLintRule { final classNode = _getContainingClass(node); if (classNode == null || - !(_isWidget(classNode) || _isStateOfWidget(classNode)) || + !(_isWidgetClass(classNode) || _isStateOfWidget(classNode)) || !_isDisposable(type)) { return; } @@ -148,8 +151,6 @@ class AvoidMissingDispose extends DartLintRule { bool _isIgnoredInstance(InterfaceType type) { return config.ignoredInstancesCheckers.any( - // Remove this once analyzer is updated - // ignore: deprecated_member_use (checker) => checker.isExactly(type.element), ); } @@ -259,25 +260,26 @@ class AvoidMissingDispose extends DartLintRule { } bool _isStateOfWidget(ClassDeclaration classNode) { - // Suggested ClassElement2 doesn't work in this case - // ignore: deprecated_member_use - if (classNode.declaredFragment case final ClassElement element) { - return element.allSupertypes.any( - (type) => type.element3.name3 == 'State', - ); - } - return false; + const stateTypeChecker = TypeChecker.fromName( + 'State', + packageName: 'flutter', + ); + return switch (classNode.declaredElement) { + final element? => + stateTypeChecker.isExactly(element) || + stateTypeChecker.isSuperOf(element), + _ => false, + }; } - bool _isWidget(ClassDeclaration classNode) { - // Suggested ClassElement2 doesn't work in this case - // ignore: deprecated_member_use - if (classNode.declaredFragment case final ClassElement element) { - return element.allSupertypes.any( - (type) => type.element3.name3 == 'Widget', - ); - } - return false; + bool _isWidgetClass(ClassDeclaration classNode) { + const widgetTypeChecker = TypeChecker.fromName( + 'Widget', + packageName: 'flutter', + ); + + return widgetTypeChecker.isExactly(classNode.declaredElement!) || + widgetTypeChecker.isSuperOf(classNode.declaredElement!); } } diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index 5fb1ba875..d0ba2c2b6 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -45,7 +45,7 @@ class _MissingDisposeStatefulWidgetState late final FocusNode _notDisposedController5; // expect_lint: avoid_missing_dispose late final ValueNotifier _notDisposedController6; - + late final AnimationController _ignoredInstance; @override From 34a0e67d398cab69c055813b6193144a0eaf6a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Tue, 12 Aug 2025 12:31:39 +0200 Subject: [PATCH 17/22] refactor: replace as with cast method --- packages/leancode_lint/lib/lints/avoid_missing_dispose.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 79dbf0e74..70607db10 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -24,9 +24,9 @@ class AvoidMissingDisposeConfig { this.ignoredInstancesCheckers = const [], }); - factory AvoidMissingDisposeConfig.fromConfig(Map json) { + factory AvoidMissingDisposeConfig.fromConfig(Map json) { final ignoredInstances = - (json['ignored_instances'] as YamlList?)?.nodes + json['ignored_instances']?.nodes .map( (e) => _IgnoredInstance( name: (e as YamlMap)['ignore'] as String, @@ -67,7 +67,7 @@ class AvoidMissingDispose extends DartLintRule { factory AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) { final config = AvoidMissingDisposeConfig.fromConfig( - configs.rules[ruleName]?.json ?? {}, + configs.rules[ruleName]?.json.cast() ?? {}, ); return AvoidMissingDispose(config: config); From 538a026be8f878bcff38a37f91657a2a01d5ceb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Tue, 12 Aug 2025 12:39:07 +0200 Subject: [PATCH 18/22] refactor: rename ignored instances to ignored types --- .../lib/lints/avoid_missing_dispose.dart | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 70607db10..22e5caa1c 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -11,8 +11,8 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:leancode_lint/utils.dart'; import 'package:yaml/yaml.dart'; -class _IgnoredInstance { - const _IgnoredInstance({required this.name, required this.packageName}); +class _IgnoredTypes { + const _IgnoredTypes({required this.name, required this.packageName}); final String name; final String packageName; @@ -20,15 +20,15 @@ class _IgnoredInstance { class AvoidMissingDisposeConfig { const AvoidMissingDisposeConfig({ - this.ignoredInstances = const {}, + this.ignoredTypes = const {}, this.ignoredInstancesCheckers = const [], }); factory AvoidMissingDisposeConfig.fromConfig(Map json) { - final ignoredInstances = + final ignoredTypes = json['ignored_instances']?.nodes .map( - (e) => _IgnoredInstance( + (e) => _IgnoredTypes( name: (e as YamlMap)['ignore'] as String, packageName: e['from_package'] as String, ), @@ -36,9 +36,9 @@ class AvoidMissingDisposeConfig { .toSet() ?? const {}; return AvoidMissingDisposeConfig( - ignoredInstances: ignoredInstances, + ignoredTypes: ignoredTypes, ignoredInstancesCheckers: [ - for (final _IgnoredInstance(:name, :packageName) in ignoredInstances) + for (final _IgnoredTypes(:name, :packageName) in ignoredTypes) if (packageName.startsWith('dart:')) TypeChecker.fromUrl('$packageName#$name') else @@ -47,7 +47,7 @@ class AvoidMissingDisposeConfig { ); } - final Set<_IgnoredInstance> ignoredInstances; + final Set<_IgnoredTypes> ignoredTypes; final List ignoredInstancesCheckers; } @@ -199,13 +199,7 @@ class AvoidMissingDispose extends DartLintRule { ConstructorDeclaration? _getConstructorDeclaration( ClassDeclaration classNode, ) { - return classNode.members.firstWhereOrNull( - (member) => switch (member) { - final ConstructorDeclaration _ => true, - _ => false, - }, - ) - as ConstructorDeclaration?; + return classNode.members.whereType().firstOrNull; } ClassDeclaration? _getContainingClass(AstNode node) { From e7c94a82ea954ba68ef756bec4910c05e9417c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Tue, 12 Aug 2025 13:47:52 +0200 Subject: [PATCH 19/22] refactor: add CR suggestions --- packages/leancode_lint/README.md | 6 +- .../lib/lints/avoid_missing_dispose.dart | 115 ++++++++---------- 2 files changed, 52 insertions(+), 69 deletions(-) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index e9c0d11ec..600adb347 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -471,11 +471,11 @@ None ### `avoid_missing_dispose` -**DO** dispose of disposable resources in StatefulWidget State classes. +**DO** dispose of resources that require `dispose()` in StatefulWidget `State` classes. -Disposable resources like controllers, and focus nodes must be properly disposed in the `dispose()` method to prevent memory leaks. +Resources such as controllers and focus nodes must be disposed in the `dispose()` method to prevent memory leaks. -**BAD:** +**BAD:**` ```dart class MyWidgetState extends State { diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index 22e5caa1c..c482124d3 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -19,10 +19,7 @@ class _IgnoredTypes { } class AvoidMissingDisposeConfig { - const AvoidMissingDisposeConfig({ - this.ignoredTypes = const {}, - this.ignoredInstancesCheckers = const [], - }); + const AvoidMissingDisposeConfig({this.ignoredTypesCheckers = const []}); factory AvoidMissingDisposeConfig.fromConfig(Map json) { final ignoredTypes = @@ -36,8 +33,7 @@ class AvoidMissingDisposeConfig { .toSet() ?? const {}; return AvoidMissingDisposeConfig( - ignoredTypes: ignoredTypes, - ignoredInstancesCheckers: [ + ignoredTypesCheckers: [ for (final _IgnoredTypes(:name, :packageName) in ignoredTypes) if (packageName.startsWith('dart:')) TypeChecker.fromUrl('$packageName#$name') @@ -47,8 +43,7 @@ class AvoidMissingDisposeConfig { ); } - final Set<_IgnoredTypes> ignoredTypes; - final List ignoredInstancesCheckers; + final List ignoredTypesCheckers; } /// Checks for proper disposal of resources in StatefulWidget classes. @@ -144,26 +139,21 @@ class AvoidMissingDispose extends DartLintRule { if (_isInReturnWidget(node)) { reporter.atNode(node, code); - return; } }); } - bool _isIgnoredInstance(InterfaceType type) { - return config.ignoredInstancesCheckers.any( - (checker) => checker.isExactly(type.element), - ); - } + bool _isIgnoredInstance(InterfaceType type) => config.ignoredTypesCheckers + .any((checker) => checker.isExactly(type.element)); InterfaceType? _getFieldDeclarationType(FieldDeclaration field) { if (field.fields.type?.type case final InterfaceType type) { return type; } - if (field.fields.variables.first.initializer?.staticType - case final InterfaceType? type) { - return type; - } - return null; + return switch (field.fields.variables.first.initializer?.staticType) { + final InterfaceType type => type, + _ => null, + }; } bool _isFieldUsedByConstructor( @@ -198,29 +188,26 @@ class AvoidMissingDispose extends DartLintRule { ConstructorDeclaration? _getConstructorDeclaration( ClassDeclaration classNode, - ) { - return classNode.members.whereType().firstOrNull; - } + ) => classNode.members.whereType().firstOrNull; ClassDeclaration? _getContainingClass(AstNode node) { var classNode = node.parent; while (classNode != null && classNode is! ClassDeclaration) { classNode = classNode.parent; } - if (classNode case final ClassDeclaration classNode) { - return classNode; - } - return null; + return switch (classNode) { + final ClassDeclaration classNode => classNode, + _ => null, + }; } - bool _isDisposable(InterfaceType type) { - return type.methods2.any((method) => method.name3 == 'dispose') || - type.element3.inheritedMembers.entries.any( - (entry) => - entry.key.name == 'dispose' && - entry.value.baseElement is MethodElement2, - ); - } + bool _isDisposable(InterfaceType type) => + type.methods2.any((method) => method.name3 == 'dispose') || + type.element3.inheritedMembers.entries.any( + (entry) => + entry.key.name == 'dispose' && + entry.value.baseElement is MethodElement2, + ); bool _isWidgetType(InterfaceType type) { const widgetTypeChecker = TypeChecker.fromName( @@ -236,14 +223,16 @@ class AvoidMissingDispose extends DartLintRule { AstNode? currentNode = node; var returnsWidget = false; var isInReturn = false; + + bool isCurrentNodeReturn() => + currentNode is ReturnStatement || currentNode is ExpressionFunctionBody; + while (currentNode != null && !(returnsWidget && isInReturn)) { - if (!isInReturn && - (currentNode is ReturnStatement || - currentNode is ExpressionFunctionBody)) { + if (!isInReturn && isCurrentNodeReturn()) { isInReturn = true; } if (currentNode case MethodDeclaration( - returnType: NamedType(type: final InterfaceType type), + returnType: NamedType(:final InterfaceType type), ) when _isWidgetType(type)) { returnsWidget = true; } @@ -253,28 +242,23 @@ class AvoidMissingDispose extends DartLintRule { return returnsWidget && isInReturn; } - bool _isStateOfWidget(ClassDeclaration classNode) { - const stateTypeChecker = TypeChecker.fromName( - 'State', - packageName: 'flutter', - ); - return switch (classNode.declaredElement) { - final element? => - stateTypeChecker.isExactly(element) || - stateTypeChecker.isSuperOf(element), - _ => false, - }; - } - - bool _isWidgetClass(ClassDeclaration classNode) { - const widgetTypeChecker = TypeChecker.fromName( - 'Widget', - packageName: 'flutter', - ); + bool _isStateOfWidget(ClassDeclaration classNode) => + switch (classNode.declaredElement) { + final element? => const TypeChecker.fromName( + 'State', + packageName: 'flutter', + ).isAssignableFrom(element), + _ => false, + }; - return widgetTypeChecker.isExactly(classNode.declaredElement!) || - widgetTypeChecker.isSuperOf(classNode.declaredElement!); - } + bool _isWidgetClass(ClassDeclaration classNode) => + switch (classNode.declaredElement) { + final element? => const TypeChecker.fromName( + 'Widget', + packageName: 'flutter', + ).isAssignableFrom(element), + _ => false, + }; } class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { @@ -318,8 +302,8 @@ class _AddDisposeMethod extends DartFix { List others, ) { if (analysisError.data case _AvoidMissingDisposeAnalysisData( - classNode: final classNode, - instanceName: final instanceName, + :final classNode, + :final instanceName, )) { final disposeMethodNode = _getStateDisposeMethod(classNode); if (disposeMethodNode?.body case BlockFunctionBody( @@ -341,11 +325,10 @@ class _AddDisposeMethod extends DartFix { } } - MethodDeclaration? _getStateDisposeMethod(ClassDeclaration classNode) { - return classNode.members.whereType().firstWhereOrNull( - (member) => member.name.lexeme == 'dispose', - ); - } + MethodDeclaration? _getStateDisposeMethod(ClassDeclaration classNode) => + classNode.members.whereType().firstWhereOrNull( + (member) => member.name.lexeme == 'dispose', + ); } class _AvoidMissingDisposeAnalysisData { From 3dcd4301c6233c82e5a6dabe924555f1db19467e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 22 Aug 2025 13:41:52 +0200 Subject: [PATCH 20/22] feat: add close dispose cancel configuration --- packages/leancode_lint/README.md | 21 +-- .../lib/lints/avoid_missing_dispose.dart | 69 +++++++--- .../test/lints_test_app/analysis_options.yaml | 6 +- .../avoid_missing_dispose_bloc_test.dart | 42 ++++++ .../avoid_missing_dispose_test.dart | 129 ++++++++++++++++++ .../lib/avoid_missing_dispose_test.dart | 8 +- 6 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart create mode 100644 packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index 600adb347..ce0ffcf82 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -480,11 +480,10 @@ Resources such as controllers and focus nodes must be disposed in the `dispose() ```dart class MyWidgetState extends State { late final TextEditingController controller = TextEditingController(); - late final FocusNode focusNode = FocusNode(); @override Widget build(BuildContext context) { - return TextField(controller: controller, focusNode: focusNode); + return TextField(controller: controller); } } ``` @@ -505,24 +504,21 @@ class MyWidgetState extends State { ```dart class MyWidgetState extends State { late final TextEditingController controller; - late final FocusNode focusNode; @override void initState() { super.initState(); controller = TextEditingController(); - focusNode = FocusNode(); } @override Widget build(BuildContext context) { - return TextField(controller: controller, focusNode: focusNode); + return TextField(controller: controller); } @override void dispose() { controller.dispose(); - focusNode.dispose(); super.dispose(); } } @@ -534,14 +530,23 @@ class MyWidgetState extends State { custom_lint: rules: - avoid_missing_dispose: - ignored_instances: + ignored_types: - ignore: AnimationController from_package: flutter + disposal_methods: + close: true + dispose: true + cancel: true + ``` -- `ignored_instances` - an optional YamlList - skips dispose checks for specified types. This allows disabling the lint rule for classes where dispose method checks are not needed. +- `ignored_types` - an optional YamlList - skips dispose checks for specified types. This allows disabling the lint rule for classes where dispose method checks are not needed. - `ignore` - A required String - name of the instance to ignore - `from_package` - A required String - name of the source package +- `disposal_methods` - an optional YamlMap - controls which disposal methods the lint rule should recognize and check for. By default, the rule looks for `dispose`, `close`, and `cancel` methods. You can selectively enable or disable checking for each of these methods. + - `dispose` - A boolean (default: true) - whether to check for `dispose()` method calls + - `close` - A boolean (default: true) - whether to check for `close()` method calls + - `cancel` - A boolean (default: true) - whether to check for `cancel()` method calls ## Assists diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart index c482124d3..adceeefd4 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart @@ -19,11 +19,14 @@ class _IgnoredTypes { } class AvoidMissingDisposeConfig { - const AvoidMissingDisposeConfig({this.ignoredTypesCheckers = const []}); + const AvoidMissingDisposeConfig({ + this.ignoredTypesCheckers = const [], + this.disposalMethods = const {}, + }); - factory AvoidMissingDisposeConfig.fromConfig(Map json) { + factory AvoidMissingDisposeConfig.fromConfig(Map json) { final ignoredTypes = - json['ignored_instances']?.nodes + (json['ignored_types'] as YamlList?)?.nodes .map( (e) => _IgnoredTypes( name: (e as YamlMap)['ignore'] as String, @@ -32,6 +35,21 @@ class AvoidMissingDisposeConfig { ) .toSet() ?? const {}; + + final disposalMethodsMap = (json['disposal_methods'] as YamlMap?)?.nodes + .map( + (key, value) => MapEntry( + (key as YamlScalar).value as String, + (value as YamlScalar).value as bool, + ), + ); + + final disposalMethods = { + if (disposalMethodsMap?['dispose'] ?? true) 'dispose', + if (disposalMethodsMap?['close'] ?? true) 'close', + if (disposalMethodsMap?['cancel'] ?? true) 'cancel', + }; + return AvoidMissingDisposeConfig( ignoredTypesCheckers: [ for (final _IgnoredTypes(:name, :packageName) in ignoredTypes) @@ -40,10 +58,12 @@ class AvoidMissingDisposeConfig { else TypeChecker.fromName(name, packageName: packageName), ], + disposalMethods: disposalMethods, ); } final List ignoredTypesCheckers; + final Set disposalMethods; } /// Checks for proper disposal of resources in StatefulWidget classes. @@ -62,7 +82,7 @@ class AvoidMissingDispose extends DartLintRule { factory AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) { final config = AvoidMissingDisposeConfig.fromConfig( - configs.rules[ruleName]?.json.cast() ?? {}, + configs.rules[ruleName]?.json.cast() ?? {}, ); return AvoidMissingDispose(config: config); @@ -99,7 +119,8 @@ class AvoidMissingDispose extends DartLintRule { return; } - if (!_isStateOfWidget(classNode) || !_isDisposable(type)) { + final disposableMethodName = _getDisposableMethodName(type); + if (!_isStateOfWidget(classNode) || disposableMethodName == null) { return; } @@ -115,6 +136,7 @@ class AvoidMissingDispose extends DartLintRule { data: _AvoidMissingDisposeAnalysisData( instanceName: node.fields.variables.first.name.lexeme, classNode: classNode, + disposeMethodName: disposableMethodName, ), ); } @@ -133,7 +155,7 @@ class AvoidMissingDispose extends DartLintRule { if (classNode == null || !(_isWidgetClass(classNode) || _isStateOfWidget(classNode)) || - !_isDisposable(type)) { + _getDisposableMethodName(type) == null) { return; } @@ -201,13 +223,21 @@ class AvoidMissingDispose extends DartLintRule { }; } - bool _isDisposable(InterfaceType type) => - type.methods2.any((method) => method.name3 == 'dispose') || - type.element3.inheritedMembers.entries.any( - (entry) => - entry.key.name == 'dispose' && - entry.value.baseElement is MethodElement2, - ); + String? _getDisposableMethodName(InterfaceType type) { + return type.methods2 + .firstWhereOrNull( + (method) => config.disposalMethods.contains(method.name3), + ) + ?.name3 ?? + type.element3.inheritedMembers.entries + .firstWhereOrNull( + (entry) => + config.disposalMethods.contains(entry.key.name) && + entry.value.baseElement is MethodElement2, + ) + ?.key + .name; + } bool _isWidgetType(InterfaceType type) { const widgetTypeChecker = TypeChecker.fromName( @@ -277,13 +307,13 @@ class _DisposeExpressionsGatherer extends GeneralizingAstVisitor { return visitor._disposeExpressions; } - static const disposeMethodName = 'dispose'; - @override void visitExpressionStatement(ExpressionStatement node) { if (node.expression case MethodInvocation( - methodName: SimpleIdentifier(name: disposeMethodName), + methodName: SimpleIdentifier( + name: 'dispose' || 'close' || 'cancel', + ), target: SimpleIdentifier(:final name), ) && final invocation when name == targetName) { @@ -304,6 +334,7 @@ class _AddDisposeMethod extends DartFix { if (analysisError.data case _AvoidMissingDisposeAnalysisData( :final classNode, :final instanceName, + :final disposeMethodName, )) { final disposeMethodNode = _getStateDisposeMethod(classNode); if (disposeMethodNode?.body case BlockFunctionBody( @@ -312,13 +343,13 @@ class _AddDisposeMethod extends DartFix { reporter .createChangeBuilder( message: - 'Add $instanceName.dispose() to the state dispose method', + 'Add $instanceName.$disposeMethodName() to the state dispose method', priority: 80, ) .addDartFileEdit((builder) { builder.addSimpleInsertion( statement.offset, - '$instanceName.dispose();\n ', + '$instanceName.$disposeMethodName();\n ', ); }); } @@ -335,8 +366,10 @@ class _AvoidMissingDisposeAnalysisData { const _AvoidMissingDisposeAnalysisData({ required this.instanceName, required this.classNode, + required this.disposeMethodName, }); final String instanceName; final ClassDeclaration classNode; + final String disposeMethodName; } diff --git a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml index 0190f3e15..4dac09f19 100644 --- a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml +++ b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml @@ -19,9 +19,13 @@ custom_lint: # TODO: remove explicit enable once enabled by default - constructor_parameters_and_fields_should_have_the_same_order: true - avoid_missing_dispose: - ignored_instances: + ignored_types: - ignore: AnimationController from_package: flutter + disposal_methods: + close: true + dispose: true + cancel: true analyzer: plugins: diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart new file mode 100644 index 000000000..8d53815b4 --- /dev/null +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart @@ -0,0 +1,42 @@ +// Test Bloc classes for avoid_missing_dispose lint +// ignore_for_file: unused_field, use_design_system_item + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CounterBloc extends Bloc { + CounterBloc() : super(0) { + on((event, emit) => emit(event + state)); + } +} + +class BlocStatefulWidget extends StatefulWidget { + const BlocStatefulWidget({super.key}); + + @override + State createState() => _BlocStatefulWidgetState(); +} + +class _BlocStatefulWidgetState extends State { + late CounterBloc _counterBloc; + // expect_lint: avoid_missing_dispose + final _notDisposedBloc = CounterBloc(); + // expect_lint: avoid_missing_dispose + late final CounterBloc _notDisposedBlocLate; + + @override + void initState() { + super.initState(); + _counterBloc = CounterBloc(); + _notDisposedBlocLate = CounterBloc(); + } + + @override + void dispose() { + _counterBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox(); +} diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart new file mode 100644 index 000000000..4b3372103 --- /dev/null +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart @@ -0,0 +1,129 @@ +// ignored for lint test purpose +// ignore_for_file: unused_field, prefer_final_fields, unused_element, use_design_system_item, use_design_system_item_LftText + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class MissingDisposeStatefulWidget extends StatefulWidget { + MissingDisposeStatefulWidget({ + super.key, + required this.scrollController, + required FocusNode focusNode, + }) : _focusNode = focusNode, + _streamController = StreamController(); + // expect_lint: avoid_missing_dispose + final controller = TextEditingController(); + final ScrollController scrollController; + final FocusNode _focusNode; + final StreamController _streamController; + + @override + State createState() => + _MissingDisposeStatefulWidgetState(); +} + +class _MissingDisposeStatefulWidgetState + extends State + with TickerProviderStateMixin { + late TextEditingController _textControllerTest; + late ScrollController _scrollController; + late FocusNode _focusNode; + late ValueNotifier _valueNotifier; + late final _focusNode2 = FocusNode(); + final _pageController = PageController(); + final _streamController = StreamController(); + // expect_lint: avoid_missing_dispose + final _streamController2 = StreamController(); + late AnimationController _animationController; + final _timer = Timer(Duration.zero, () {}); + final _timer2 = Timer(Duration.zero, () {}); + + // expect_lint: avoid_missing_dispose + var _notDisposedController = ScrollController(); + // expect_lint: avoid_missing_dispose + late final _notDisposedController2 = FocusNode(); + // expect_lint: avoid_missing_dispose + final _notDisposedController3 = ValueNotifier(0); + // expect_lint: avoid_missing_dispose + late final ScrollController _notDisposedController4; + // expect_lint: avoid_missing_dispose + late final FocusNode _notDisposedController5; + // expect_lint: avoid_missing_dispose + late final ValueNotifier _notDisposedController6; + // expect_lint: avoid_missing_dispose + late final Timer _notDisposedTimer; + + late final AnimationController _ignoredInstance; + + @override + void dispose() { + _timer.cancel(); + _streamController.close(); + _textControllerTest.dispose(); + _scrollController.dispose(); + _valueNotifier.dispose(); + _focusNode.dispose(); + _focusNode2.dispose(); + _pageController.dispose(); + super.dispose(); + } + + void _disposeAnimationController() { + _animationController.dispose(); + } + + void _disposeTimer() { + _timer2.cancel(); + } + + @override + Widget build(BuildContext context) { + final animationController2 = useAnimationController( + duration: const Duration(seconds: 1), + ); + return Column( + children: [ + // expect_lint: avoid_missing_dispose + TextField(controller: TextEditingController()), + _buildTextField(), + AnimatedBuilder( + animation: animationController2, + builder: (context, child) { + return Container( + color: Colors.red, + height: animationController2.value, + ); + }, + ), + ], + ); + } + + TextField _buildTextField() { + // expect_lint: avoid_missing_dispose + return TextField(controller: TextEditingController()); + } +} + +class StatelessMissingDisposeWidget extends StatelessWidget { + StatelessMissingDisposeWidget({ + super.key, + required this.scrollController, + required FocusNode focusNode, + }) : _focusNode = focusNode; + + // expect_lint: avoid_missing_dispose + final controller = TextEditingController(); + final ScrollController scrollController; + final FocusNode _focusNode; + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } +} + +class RegularClass { + final controller = TextEditingController(); +} diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart index d0ba2c2b6..1b5823382 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart @@ -1,5 +1,5 @@ // ignored for lint test purpose -// ignore_for_file: unused_field, prefer_final_fields, unused_element +// ignore_for_file: unused_field, prefer_final_fields, unused_element, use_design_system_item, use_design_system_item_LftText import 'dart:async'; import 'package:flutter/material.dart'; @@ -10,11 +10,13 @@ class MissingDisposeStatefulWidget extends StatefulWidget { super.key, required this.scrollController, required FocusNode focusNode, - }) : _focusNode = focusNode; + }) : _focusNode = focusNode, + _streamController = StreamController(); // expect_lint: avoid_missing_dispose final controller = TextEditingController(); final ScrollController scrollController; final FocusNode _focusNode; + final StreamController _streamController; @override State createState() => @@ -50,10 +52,10 @@ class _MissingDisposeStatefulWidgetState @override void dispose() { + _streamController.close(); _textControllerTest.dispose(); _scrollController.dispose(); _valueNotifier.dispose(); - _streamController.close(); _focusNode.dispose(); _focusNode2.dispose(); _pageController.dispose(); From 6783459f252ba71ef32bf4bb92236311d109852a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 22 Aug 2025 13:50:04 +0200 Subject: [PATCH 21/22] refactor: change name from avoid_missing_dispose to missing_cleanup --- packages/leancode_lint/README.md | 12 +- packages/leancode_lint/lib/leancode_lint.dart | 4 +- ...sing_dispose.dart => missing_cleanup.dart} | 53 ++++---- .../test/lints_test_app/analysis_options.yaml | 4 +- .../lib/avoid_conditional_hooks_test.dart | 2 +- .../lib/avoid_missing_dispose_test.dart | 118 ------------------ .../missing_cleanup_dispose_bloc_test.dart} | 4 +- .../missing_cleanup_test.dart} | 24 ++-- 8 files changed, 51 insertions(+), 170 deletions(-) rename packages/leancode_lint/lib/lints/{avoid_missing_dispose.dart => missing_cleanup.dart} (88%) delete mode 100644 packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart rename packages/leancode_lint/test/lints_test_app/lib/{avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart => missing_cleanup/missing_cleanup_dispose_bloc_test.dart} (92%) rename packages/leancode_lint/test/lints_test_app/lib/{avoid_missing_dispose/avoid_missing_dispose_test.dart => missing_cleanup/missing_cleanup_test.dart} (87%) diff --git a/packages/leancode_lint/README.md b/packages/leancode_lint/README.md index ce0ffcf82..dd8f44904 100644 --- a/packages/leancode_lint/README.md +++ b/packages/leancode_lint/README.md @@ -469,11 +469,11 @@ class MyWidget extends StatelessWidget { None -### `avoid_missing_dispose` +### `missing_cleanup` -**DO** dispose of resources that require `dispose()` in StatefulWidget `State` classes. +**DO** cleanup of resources that require `dispose()`, `close()` or `cancel()` in StatefulWidget `State` classes. -Resources such as controllers and focus nodes must be disposed in the `dispose()` method to prevent memory leaks. +Resources such as controllers, stream controllers or focus nodes must be cleanup in the `dispose()` method to prevent memory leaks. **BAD:**` @@ -529,11 +529,11 @@ class MyWidgetState extends State { ```yaml custom_lint: rules: - - avoid_missing_dispose: + - missing_cleanup: ignored_types: - ignore: AnimationController from_package: flutter - disposal_methods: + cleanup_methods: close: true dispose: true cancel: true @@ -543,7 +543,7 @@ custom_lint: - `ignored_types` - an optional YamlList - skips dispose checks for specified types. This allows disabling the lint rule for classes where dispose method checks are not needed. - `ignore` - A required String - name of the instance to ignore - `from_package` - A required String - name of the source package -- `disposal_methods` - an optional YamlMap - controls which disposal methods the lint rule should recognize and check for. By default, the rule looks for `dispose`, `close`, and `cancel` methods. You can selectively enable or disable checking for each of these methods. +- `cleanup_methods` - an optional YamlMap - controls which disposal methods the lint rule should recognize and check for. By default, the rule looks for `dispose`, `close`, and `cancel` methods. You can selectively enable or disable checking for each of these methods. - `dispose` - A boolean (default: true) - whether to check for `dispose()` method calls - `close` - A boolean (default: true) - whether to check for `close()` method calls - `cancel` - A boolean (default: true) - whether to check for `cancel()` method calls diff --git a/packages/leancode_lint/lib/leancode_lint.dart b/packages/leancode_lint/lib/leancode_lint.dart index af906d4ba..acddb472e 100644 --- a/packages/leancode_lint/lib/leancode_lint.dart +++ b/packages/leancode_lint/lib/leancode_lint.dart @@ -4,12 +4,12 @@ import 'package:leancode_lint/assists/convert_positional_to_named_formal.dart'; import 'package:leancode_lint/assists/convert_record_into_nominal_type.dart'; import 'package:leancode_lint/lints/add_cubit_suffix_for_cubits.dart'; import 'package:leancode_lint/lints/avoid_conditional_hooks.dart'; -import 'package:leancode_lint/lints/avoid_missing_dispose.dart'; import 'package:leancode_lint/lints/avoid_single_child_in_multi_child_widget.dart'; import 'package:leancode_lint/lints/catch_parameter_names.dart'; import 'package:leancode_lint/lints/constructor_parameters_and_fields_should_have_the_same_order.dart'; import 'package:leancode_lint/lints/hook_widget_does_not_use_hooks.dart'; import 'package:leancode_lint/lints/prefer_center_over_align.dart'; +import 'package:leancode_lint/lints/missing_cleanup.dart'; import 'package:leancode_lint/lints/prefix_widgets_returning_slivers.dart'; import 'package:leancode_lint/lints/start_comments_with_space.dart'; import 'package:leancode_lint/lints/use_align.dart'; @@ -35,7 +35,7 @@ class _Linter extends PluginBase { const UsePadding(), const UseDedicatedMediaQueryMethods(), const PreferCenterOverAlign(), - AvoidMissingDispose.fromConfigs(configs), + MissingCleanup.fromConfigs(configs), ]; @override diff --git a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart b/packages/leancode_lint/lib/lints/missing_cleanup.dart similarity index 88% rename from packages/leancode_lint/lib/lints/avoid_missing_dispose.dart rename to packages/leancode_lint/lib/lints/missing_cleanup.dart index adceeefd4..18597b265 100644 --- a/packages/leancode_lint/lib/lints/avoid_missing_dispose.dart +++ b/packages/leancode_lint/lib/lints/missing_cleanup.dart @@ -18,13 +18,13 @@ class _IgnoredTypes { final String packageName; } -class AvoidMissingDisposeConfig { - const AvoidMissingDisposeConfig({ +class MissingCleanupConfig { + const MissingCleanupConfig({ this.ignoredTypesCheckers = const [], - this.disposalMethods = const {}, + this.cleanupMethods = const {}, }); - factory AvoidMissingDisposeConfig.fromConfig(Map json) { + factory MissingCleanupConfig.fromConfig(Map json) { final ignoredTypes = (json['ignored_types'] as YamlList?)?.nodes .map( @@ -36,21 +36,20 @@ class AvoidMissingDisposeConfig { .toSet() ?? const {}; - final disposalMethodsMap = (json['disposal_methods'] as YamlMap?)?.nodes - .map( - (key, value) => MapEntry( - (key as YamlScalar).value as String, - (value as YamlScalar).value as bool, - ), - ); + final cleanupMethodsMap = (json['cleanup_methods'] as YamlMap?)?.nodes.map( + (key, value) => MapEntry( + (key as YamlScalar).value as String, + (value as YamlScalar).value as bool, + ), + ); - final disposalMethods = { - if (disposalMethodsMap?['dispose'] ?? true) 'dispose', - if (disposalMethodsMap?['close'] ?? true) 'close', - if (disposalMethodsMap?['cancel'] ?? true) 'cancel', + final cleanupMethods = { + if (cleanupMethodsMap?['dispose'] ?? true) 'dispose', + if (cleanupMethodsMap?['close'] ?? true) 'close', + if (cleanupMethodsMap?['cancel'] ?? true) 'cancel', }; - return AvoidMissingDisposeConfig( + return MissingCleanupConfig( ignoredTypesCheckers: [ for (final _IgnoredTypes(:name, :packageName) in ignoredTypes) if (packageName.startsWith('dart:')) @@ -58,18 +57,18 @@ class AvoidMissingDisposeConfig { else TypeChecker.fromName(name, packageName: packageName), ], - disposalMethods: disposalMethods, + cleanupMethods: cleanupMethods, ); } final List ignoredTypesCheckers; - final Set disposalMethods; + final Set cleanupMethods; } /// Checks for proper disposal of resources in StatefulWidget classes. /// Warns when disposable resources are not properly disposed in the dispose() method. -class AvoidMissingDispose extends DartLintRule { - const AvoidMissingDispose({required this.config}) +class MissingCleanup extends DartLintRule { + const MissingCleanup({required this.config}) : super( code: const LintCode( name: ruleName, @@ -80,17 +79,17 @@ class AvoidMissingDispose extends DartLintRule { ), ); - factory AvoidMissingDispose.fromConfigs(CustomLintConfigs configs) { - final config = AvoidMissingDisposeConfig.fromConfig( + factory MissingCleanup.fromConfigs(CustomLintConfigs configs) { + final config = MissingCleanupConfig.fromConfig( configs.rules[ruleName]?.json.cast() ?? {}, ); - return AvoidMissingDispose(config: config); + return MissingCleanup(config: config); } - final AvoidMissingDisposeConfig config; + final MissingCleanupConfig config; - static const ruleName = 'avoid_missing_dispose'; + static const ruleName = 'missing_cleanup'; @override List getFixes() => [_AddDisposeMethod()]; @@ -226,13 +225,13 @@ class AvoidMissingDispose extends DartLintRule { String? _getDisposableMethodName(InterfaceType type) { return type.methods2 .firstWhereOrNull( - (method) => config.disposalMethods.contains(method.name3), + (method) => config.cleanupMethods.contains(method.name3), ) ?.name3 ?? type.element3.inheritedMembers.entries .firstWhereOrNull( (entry) => - config.disposalMethods.contains(entry.key.name) && + config.cleanupMethods.contains(entry.key.name) && entry.value.baseElement is MethodElement2, ) ?.key diff --git a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml index 4dac09f19..8e3e4d061 100644 --- a/packages/leancode_lint/test/lints_test_app/analysis_options.yaml +++ b/packages/leancode_lint/test/lints_test_app/analysis_options.yaml @@ -18,11 +18,11 @@ custom_lint: application_prefix: Lncd # TODO: remove explicit enable once enabled by default - constructor_parameters_and_fields_should_have_the_same_order: true - - avoid_missing_dispose: + - missing_cleanup: ignored_types: - ignore: AnimationController from_package: flutter - disposal_methods: + cleanup_methods: close: true dispose: true cancel: true diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart index 57b5078ce..78e3b1f83 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/avoid_conditional_hooks_test.dart @@ -1,5 +1,5 @@ // Not related to the test -// ignore_for_file: avoid_missing_dispose +// ignore_for_file: missing_cleanup import 'dart:math'; import 'package:flutter/material.dart'; diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart deleted file mode 100644 index 1b5823382..000000000 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose_test.dart +++ /dev/null @@ -1,118 +0,0 @@ -// ignored for lint test purpose -// ignore_for_file: unused_field, prefer_final_fields, unused_element, use_design_system_item, use_design_system_item_LftText - -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -class MissingDisposeStatefulWidget extends StatefulWidget { - MissingDisposeStatefulWidget({ - super.key, - required this.scrollController, - required FocusNode focusNode, - }) : _focusNode = focusNode, - _streamController = StreamController(); - // expect_lint: avoid_missing_dispose - final controller = TextEditingController(); - final ScrollController scrollController; - final FocusNode _focusNode; - final StreamController _streamController; - - @override - State createState() => - _MissingDisposeStatefulWidgetState(); -} - -class _MissingDisposeStatefulWidgetState - extends State - with TickerProviderStateMixin { - late TextEditingController _textControllerTest; - late ScrollController _scrollController; - late FocusNode _focusNode; - late ValueNotifier _valueNotifier; - late final _focusNode2 = FocusNode(); - final _pageController = PageController(); - final _streamController = StreamController(); - late AnimationController _animationController; - - // expect_lint: avoid_missing_dispose - var _notDisposedController = ScrollController(); - // expect_lint: avoid_missing_dispose - late final _notDisposedController2 = FocusNode(); - // expect_lint: avoid_missing_dispose - final _notDisposedController3 = ValueNotifier(0); - // expect_lint: avoid_missing_dispose - late final ScrollController _notDisposedController4; - // expect_lint: avoid_missing_dispose - late final FocusNode _notDisposedController5; - // expect_lint: avoid_missing_dispose - late final ValueNotifier _notDisposedController6; - - late final AnimationController _ignoredInstance; - - @override - void dispose() { - _streamController.close(); - _textControllerTest.dispose(); - _scrollController.dispose(); - _valueNotifier.dispose(); - _focusNode.dispose(); - _focusNode2.dispose(); - _pageController.dispose(); - super.dispose(); - } - - void _disposeAnimationController() { - _animationController.dispose(); - } - - @override - Widget build(BuildContext context) { - final animationController2 = useAnimationController( - duration: const Duration(seconds: 1), - ); - return Column( - children: [ - // expect_lint: avoid_missing_dispose - TextField(controller: TextEditingController()), - _buildTextField(), - AnimatedBuilder( - animation: animationController2, - builder: (context, child) { - return Container( - color: Colors.red, - height: animationController2.value, - ); - }, - ), - ], - ); - } - - TextField _buildTextField() { - // expect_lint: avoid_missing_dispose - return TextField(controller: TextEditingController()); - } -} - -class StatelessMissingDisposeWidget extends StatelessWidget { - StatelessMissingDisposeWidget({ - super.key, - required this.scrollController, - required FocusNode focusNode, - }) : _focusNode = focusNode; - - // expect_lint: avoid_missing_dispose - final controller = TextEditingController(); - final ScrollController scrollController; - final FocusNode _focusNode; - - @override - Widget build(BuildContext context) { - return const SizedBox(); - } -} - -class RegularClass { - final controller = TextEditingController(); -} diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart b/packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_dispose_bloc_test.dart similarity index 92% rename from packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart rename to packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_dispose_bloc_test.dart index 8d53815b4..a8c53631a 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_bloc_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_dispose_bloc_test.dart @@ -19,9 +19,9 @@ class BlocStatefulWidget extends StatefulWidget { class _BlocStatefulWidgetState extends State { late CounterBloc _counterBloc; - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup final _notDisposedBloc = CounterBloc(); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final CounterBloc _notDisposedBlocLate; @override diff --git a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart b/packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_test.dart similarity index 87% rename from packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart rename to packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_test.dart index 4b3372103..ca371863d 100644 --- a/packages/leancode_lint/test/lints_test_app/lib/avoid_missing_dispose/avoid_missing_dispose_test.dart +++ b/packages/leancode_lint/test/lints_test_app/lib/missing_cleanup/missing_cleanup_test.dart @@ -12,7 +12,7 @@ class MissingDisposeStatefulWidget extends StatefulWidget { required FocusNode focusNode, }) : _focusNode = focusNode, _streamController = StreamController(); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup final controller = TextEditingController(); final ScrollController scrollController; final FocusNode _focusNode; @@ -33,25 +33,25 @@ class _MissingDisposeStatefulWidgetState late final _focusNode2 = FocusNode(); final _pageController = PageController(); final _streamController = StreamController(); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup final _streamController2 = StreamController(); late AnimationController _animationController; final _timer = Timer(Duration.zero, () {}); final _timer2 = Timer(Duration.zero, () {}); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup var _notDisposedController = ScrollController(); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final _notDisposedController2 = FocusNode(); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup final _notDisposedController3 = ValueNotifier(0); - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final ScrollController _notDisposedController4; - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final FocusNode _notDisposedController5; - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final ValueNotifier _notDisposedController6; - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup late final Timer _notDisposedTimer; late final AnimationController _ignoredInstance; @@ -84,7 +84,7 @@ class _MissingDisposeStatefulWidgetState ); return Column( children: [ - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup TextField(controller: TextEditingController()), _buildTextField(), AnimatedBuilder( @@ -101,7 +101,7 @@ class _MissingDisposeStatefulWidgetState } TextField _buildTextField() { - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup return TextField(controller: TextEditingController()); } } @@ -113,7 +113,7 @@ class StatelessMissingDisposeWidget extends StatelessWidget { required FocusNode focusNode, }) : _focusNode = focusNode; - // expect_lint: avoid_missing_dispose + // expect_lint: missing_cleanup final controller = TextEditingController(); final ScrollController scrollController; final FocusNode _focusNode; From 1030a5fd34c9b10672046dc1b18bdce3879545da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Wi=C5=9Bniewski?= Date: Fri, 22 Aug 2025 14:20:57 +0200 Subject: [PATCH 22/22] refactor: leancode_lint imports order --- packages/leancode_lint/lib/leancode_lint.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/leancode_lint/lib/leancode_lint.dart b/packages/leancode_lint/lib/leancode_lint.dart index acddb472e..d99886e44 100644 --- a/packages/leancode_lint/lib/leancode_lint.dart +++ b/packages/leancode_lint/lib/leancode_lint.dart @@ -8,8 +8,8 @@ import 'package:leancode_lint/lints/avoid_single_child_in_multi_child_widget.dar import 'package:leancode_lint/lints/catch_parameter_names.dart'; import 'package:leancode_lint/lints/constructor_parameters_and_fields_should_have_the_same_order.dart'; import 'package:leancode_lint/lints/hook_widget_does_not_use_hooks.dart'; -import 'package:leancode_lint/lints/prefer_center_over_align.dart'; import 'package:leancode_lint/lints/missing_cleanup.dart'; +import 'package:leancode_lint/lints/prefer_center_over_align.dart'; import 'package:leancode_lint/lints/prefix_widgets_returning_slivers.dart'; import 'package:leancode_lint/lints/start_comments_with_space.dart'; import 'package:leancode_lint/lints/use_align.dart';