@sun-asterisk/sunlint 1.3.43 → 1.3.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart_analyzer/README.md +226 -0
- package/dart_analyzer/analysis_options.yaml +66 -0
- package/dart_analyzer/bin/sunlint-dart-macos +0 -0
- package/dart_analyzer/bin/sunlint_dart_analyzer.dart +124 -0
- package/dart_analyzer/lib/analyzer_service.dart +625 -0
- package/dart_analyzer/lib/json_rpc_server.dart +275 -0
- package/dart_analyzer/lib/models/rule.dart +67 -0
- package/dart_analyzer/lib/models/symbol_table.dart +607 -0
- package/dart_analyzer/lib/models/violation.dart +69 -0
- package/dart_analyzer/lib/rules/base_analyzer.dart +52 -0
- package/dart_analyzer/lib/rules/common/C002_no_duplicate_code.dart +344 -0
- package/dart_analyzer/lib/rules/common/C003_no_vague_abbreviations.dart +318 -0
- package/dart_analyzer/lib/rules/common/C006_function_naming.dart +219 -0
- package/dart_analyzer/lib/rules/common/C008_variable_declaration_locality.dart +205 -0
- package/dart_analyzer/lib/rules/common/C010_limit_block_nesting.dart +162 -0
- package/dart_analyzer/lib/rules/common/C012_command_query_separation.dart +214 -0
- package/dart_analyzer/lib/rules/common/C013_no_dead_code.dart +225 -0
- package/dart_analyzer/lib/rules/common/C014_dependency_injection.dart +249 -0
- package/dart_analyzer/lib/rules/common/C017_constructor_logic.dart +158 -0
- package/dart_analyzer/lib/rules/common/C018_no_throw_generic_error.dart +141 -0
- package/dart_analyzer/lib/rules/common/C019_log_level_usage.dart +165 -0
- package/dart_analyzer/lib/rules/common/C020_unused_imports.dart +128 -0
- package/dart_analyzer/lib/rules/common/C021_import_organization.dart +86 -0
- package/dart_analyzer/lib/rules/common/C023_no_duplicate_variable.dart +112 -0
- package/dart_analyzer/lib/rules/common/C024_no_scatter_hardcoded_constants.dart +79 -0
- package/dart_analyzer/lib/rules/common/C029_catch_block_logging.dart +81 -0
- package/dart_analyzer/lib/rules/common/C030_use_custom_error_classes.dart +77 -0
- package/dart_analyzer/lib/rules/common/C031_validation_separation.dart +90 -0
- package/dart_analyzer/lib/rules/common/C033_separate_service_repository.dart +80 -0
- package/dart_analyzer/lib/rules/common/C035_error_logging_context.dart +148 -0
- package/dart_analyzer/lib/rules/common/C040_centralized_validation.dart +84 -0
- package/dart_analyzer/lib/rules/common/C041_no_sensitive_hardcode.dart +103 -0
- package/dart_analyzer/lib/rules/common/C042_boolean_name_prefix.dart +105 -0
- package/dart_analyzer/lib/rules/common/C043_no_console_or_print.dart +101 -0
- package/dart_analyzer/lib/rules/common/C047_no_duplicate_retry_logic.dart +94 -0
- package/dart_analyzer/lib/rules/common/C048_no_bypass_architectural_layers.dart +132 -0
- package/dart_analyzer/lib/rules/common/C052_parsing_or_data_transformation.dart +95 -0
- package/dart_analyzer/lib/rules/common/C060_no_override_superclass.dart +81 -0
- package/dart_analyzer/lib/rules/common/C065_one_behavior_per_test.dart +83 -0
- package/dart_analyzer/lib/rules/common/C067_no_hardcoded_config.dart +89 -0
- package/dart_analyzer/lib/rules/common/C070_no_real_time_tests.dart +99 -0
- package/dart_analyzer/lib/rules/common/C072_single_test_behavior.dart +78 -0
- package/dart_analyzer/lib/rules/common/C073_validate_required_config_on_startup.dart +82 -0
- package/dart_analyzer/lib/rules/common/C075_explicit_return_types.dart +85 -0
- package/dart_analyzer/lib/rules/common/C076_explicit_function_types.dart +104 -0
- package/dart_analyzer/lib/rules/dart/D001_recommended_lint_rules.dart +309 -0
- package/dart_analyzer/lib/rules/dart/D002_dispose_resources.dart +338 -0
- package/dart_analyzer/lib/rules/dart/D003_prefer_widgets_over_methods.dart +273 -0
- package/dart_analyzer/lib/rules/dart/D004_avoid_shrinkwrap_listview.dart +154 -0
- package/dart_analyzer/lib/rules/dart/D005_limit_widget_nesting.dart +265 -0
- package/dart_analyzer/lib/rules/dart/D006_prefer_extracting_large_callbacks.dart +135 -0
- package/dart_analyzer/lib/rules/dart/D007_prefer_init_first_dispose_last.dart +150 -0
- package/dart_analyzer/lib/rules/dart/D008_avoid_long_functions.dart +394 -0
- package/dart_analyzer/lib/rules/dart/D009_limit_function_parameters.dart +179 -0
- package/dart_analyzer/lib/rules/dart/D010_limit_cyclomatic_complexity.dart +257 -0
- package/dart_analyzer/lib/rules/dart/D011_prefer_named_parameters.dart +152 -0
- package/dart_analyzer/lib/rules/dart/D012_prefer_named_boolean_parameters.dart +156 -0
- package/dart_analyzer/lib/rules/dart/D013_single_public_class.dart +246 -0
- package/dart_analyzer/lib/rules/dart/D014_unsafe_collection_access.dart +202 -0
- package/dart_analyzer/lib/rules/dart/D015_copywith_all_parameters.dart +125 -0
- package/dart_analyzer/lib/rules/dart/D016_project_should_have_tests.dart +134 -0
- package/dart_analyzer/lib/rules/dart/D017_pubspec_dependencies_review.dart +187 -0
- package/dart_analyzer/lib/rules/dart/D018_remove_commented_code.dart +196 -0
- package/dart_analyzer/lib/rules/dart/D019_avoid_single_child_multi_child_widget.dart +161 -0
- package/dart_analyzer/lib/rules/dart/D020_limit_if_else_branches.dart +125 -0
- package/dart_analyzer/lib/rules/dart/D021_avoid_negated_boolean_checks.dart +227 -0
- package/dart_analyzer/lib/rules/dart/D022_use_setstate_correctly.dart +269 -0
- package/dart_analyzer/lib/rules/dart/D023_avoid_unnecessary_method_overrides.dart +191 -0
- package/dart_analyzer/lib/rules/dart/D024_avoid_unnecessary_stateful_widget.dart +194 -0
- package/dart_analyzer/lib/rules/dart/D025_avoid_nested_conditional_expressions.dart +90 -0
- package/dart_analyzer/lib/rules/security/S001_backend_auth_communications.dart +155 -0
- package/dart_analyzer/lib/rules/security/S002_os_command_injection.dart +159 -0
- package/dart_analyzer/lib/rules/security/S003_open_redirect_protection.dart +208 -0
- package/dart_analyzer/lib/rules/security/S004_sensitive_data_logging.dart +391 -0
- package/dart_analyzer/lib/rules/security/S005_trusted_service_authorization.dart +182 -0
- package/dart_analyzer/lib/rules/security/S006_no_default_credentials.dart +208 -0
- package/dart_analyzer/lib/rules/security/S007_output_encoding.dart +224 -0
- package/dart_analyzer/lib/rules/security/S008_svg_content_sanitization.dart +211 -0
- package/dart_analyzer/lib/rules/security/S009_no_insecure_encryption.dart +160 -0
- package/dart_analyzer/lib/rules/security/S010_use_csprng.dart +184 -0
- package/dart_analyzer/lib/rules/security/S011_ech_tls_config.dart +175 -0
- package/dart_analyzer/lib/rules/security/S012_hardcoded_secrets.dart +255 -0
- package/dart_analyzer/lib/rules/security/S013_tls_enforcement.dart +148 -0
- package/dart_analyzer/lib/rules/security/S014_tls_version_enforcement.dart +117 -0
- package/dart_analyzer/lib/rules/security/S015_insecure_tls_certificate.dart +315 -0
- package/dart_analyzer/lib/rules/security/S016_no_sensitive_querystring.dart +244 -0
- package/dart_analyzer/lib/rules/security/S017_use_parameterized_queries.dart +191 -0
- package/dart_analyzer/lib/rules/security/S018_no_sensitive_browser_storage.dart +175 -0
- package/dart_analyzer/lib/rules/security/S019_smtp_injection_protection.dart +166 -0
- package/dart_analyzer/lib/rules/security/S020_no_eval_dynamic_code.dart +149 -0
- package/dart_analyzer/lib/rules/security/S021_referrer_policy.dart +146 -0
- package/dart_analyzer/lib/rules/security/S022_escape_output_context.dart +111 -0
- package/dart_analyzer/lib/rules/security/S023_no_json_injection.dart +550 -0
- package/dart_analyzer/lib/rules/security/S024_xpath_xxe_protection.dart +299 -0
- package/dart_analyzer/lib/rules/security/S025_server_side_validation.dart +140 -0
- package/dart_analyzer/lib/rules/security/S026_tls_all_connections.dart +196 -0
- package/dart_analyzer/lib/rules/security/S027_mtls_certificate_validation.dart +195 -0
- package/dart_analyzer/lib/rules/security/S028_file_upload_size_limits.dart +186 -0
- package/dart_analyzer/lib/rules/security/S029_csrf_protection.dart +171 -0
- package/dart_analyzer/lib/rules/security/S030_directory_browsing_protection.dart +144 -0
- package/dart_analyzer/lib/rules/security/S031_secure_session_cookies.dart +118 -0
- package/dart_analyzer/lib/rules/security/S032_httponly_session_cookies.dart +114 -0
- package/dart_analyzer/lib/rules/security/S033_samesite_session_cookies.dart +120 -0
- package/dart_analyzer/lib/rules/security/S034_host_prefix_session_cookies.dart +160 -0
- package/dart_analyzer/lib/rules/security/S035_separate_app_hostnames.dart +117 -0
- package/dart_analyzer/lib/rules/security/S036_lfi_rfi_protection.dart +188 -0
- package/dart_analyzer/lib/rules/security/S037_cache_headers.dart +113 -0
- package/dart_analyzer/lib/rules/security/S038_no_version_headers.dart +114 -0
- package/dart_analyzer/lib/rules/security/S039_tls_certificate_validation.dart +131 -0
- package/dart_analyzer/lib/rules/security/S040_session_fixation_protection.dart +155 -0
- package/dart_analyzer/lib/rules/security/S041_session_token_invalidation.dart +201 -0
- package/dart_analyzer/lib/rules/security/S042_require_re_authentication_for_long_lived.dart +158 -0
- package/dart_analyzer/lib/rules/security/S043_password_changes_invalidate_all_sessions.dart +88 -0
- package/dart_analyzer/lib/rules/security/S044_re_authentication_required.dart +119 -0
- package/dart_analyzer/lib/rules/security/S045_brute_force_protection.dart +253 -0
- package/dart_analyzer/lib/rules/security/S046_jwt_algorithm_allowlist.dart +113 -0
- package/dart_analyzer/lib/rules/security/S047_oauth_pkce_protection.dart +124 -0
- package/dart_analyzer/lib/rules/security/S048_oauth_redirect_uri_validation.dart +134 -0
- package/dart_analyzer/lib/rules/security/S049_short_validity_tokens.dart +145 -0
- package/dart_analyzer/lib/rules/security/S050_reference_tokens_entropy.dart +234 -0
- package/dart_analyzer/lib/rules/security/S051_password_length_policy.dart +171 -0
- package/dart_analyzer/lib/rules/security/S052_weak_otp_entropy.dart +107 -0
- package/dart_analyzer/lib/rules/security/S053_generic_error_messages.dart +159 -0
- package/dart_analyzer/lib/rules/security/S054_no_default_accounts.dart +141 -0
- package/dart_analyzer/lib/rules/security/S055_content_type_validation.dart +324 -0
- package/dart_analyzer/lib/rules/security/S056_log_injection_protection.dart +119 -0
- package/dart_analyzer/lib/rules/security/S057_utc_logging.dart +114 -0
- package/dart_analyzer/lib/rules/security/S058_no_ssrf.dart +175 -0
- package/dart_analyzer/lib/rules/security/S059_disable_debug_mode.dart +172 -0
- package/dart_analyzer/lib/rules/security/S060_password_minimum_length.dart +170 -0
- package/dart_analyzer/lib/symbol_table_extractor.dart +510 -0
- package/dart_analyzer/lib/utils/common_utils.dart +26 -0
- package/dart_analyzer/pubspec.lock +557 -0
- package/dart_analyzer/pubspec.yaml +39 -0
- package/dart_analyzer/test/fixtures/complex_code.dart +95 -0
- package/docs/GENERATED_FILE_HANDLING_SUMMARY.md +2 -2
- package/package.json +3 -2
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import 'package:analyzer/dart/ast/ast.dart';
|
|
2
|
+
import 'package:analyzer/dart/ast/visitor.dart';
|
|
3
|
+
import 'package:analyzer/source/line_info.dart';
|
|
4
|
+
|
|
5
|
+
import '../../models/rule.dart';
|
|
6
|
+
import '../../models/violation.dart';
|
|
7
|
+
import '../base_analyzer.dart';
|
|
8
|
+
|
|
9
|
+
/// D002: Always Dispose Resources and Remove Listeners
|
|
10
|
+
/// Ensures all resources (Controllers, StreamSubscriptions, FocusNodes) are properly disposed
|
|
11
|
+
///
|
|
12
|
+
/// Important: Only checks owned resources (created locally), not external resources (from constructor)
|
|
13
|
+
class D002DisposeResourcesAnalyzer extends BaseAnalyzer {
|
|
14
|
+
@override
|
|
15
|
+
String get ruleId => 'D002';
|
|
16
|
+
|
|
17
|
+
// Types that require disposal (exact matches)
|
|
18
|
+
static const _disposableTypes = [
|
|
19
|
+
'Controller',
|
|
20
|
+
'StreamSubscription',
|
|
21
|
+
'FocusNode',
|
|
22
|
+
'AnimationController',
|
|
23
|
+
'TextEditingController',
|
|
24
|
+
'ScrollController',
|
|
25
|
+
'TabController',
|
|
26
|
+
'PageController',
|
|
27
|
+
'VideoPlayerController',
|
|
28
|
+
'CameraController',
|
|
29
|
+
'ChangeNotifier',
|
|
30
|
+
'ValueNotifier',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Pattern-based suffixes that commonly indicate disposable resources
|
|
34
|
+
static const _disposableSuffixes = [
|
|
35
|
+
'Controller',
|
|
36
|
+
'Disposable',
|
|
37
|
+
'Subscription',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Methods that require corresponding cleanup
|
|
41
|
+
static const _methodPairsRequiringCleanup = {
|
|
42
|
+
'subscribe': ['unsubscribe', 'cancel'],
|
|
43
|
+
'addListener': ['removeListener'],
|
|
44
|
+
'listen': ['cancel', 'pause'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
@override
|
|
48
|
+
List<Violation> analyze({
|
|
49
|
+
required CompilationUnit unit,
|
|
50
|
+
required String filePath,
|
|
51
|
+
required Rule rule,
|
|
52
|
+
required LineInfo lineInfo,
|
|
53
|
+
}) {
|
|
54
|
+
final violations = <Violation>[];
|
|
55
|
+
|
|
56
|
+
final visitor = _D002Visitor(
|
|
57
|
+
filePath: filePath,
|
|
58
|
+
lineInfo: lineInfo,
|
|
59
|
+
violations: violations,
|
|
60
|
+
analyzer: this,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
unit.accept(visitor);
|
|
64
|
+
|
|
65
|
+
return violations;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class _D002Visitor extends RecursiveAstVisitor<void> {
|
|
70
|
+
final String filePath;
|
|
71
|
+
final LineInfo lineInfo;
|
|
72
|
+
final List<Violation> violations;
|
|
73
|
+
final D002DisposeResourcesAnalyzer analyzer;
|
|
74
|
+
|
|
75
|
+
// Track disposable resources per class
|
|
76
|
+
final Map<String, List<_DisposableResource>> _disposableResourcesByClass = {};
|
|
77
|
+
// Track resources created by method calls (subscribe, addListener, etc.)
|
|
78
|
+
final Map<String, List<_DisposableResource>> _methodBasedResourcesByClass = {};
|
|
79
|
+
String? _currentClassName;
|
|
80
|
+
|
|
81
|
+
_D002Visitor({
|
|
82
|
+
required this.filePath,
|
|
83
|
+
required this.lineInfo,
|
|
84
|
+
required this.violations,
|
|
85
|
+
required this.analyzer,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
void visitClassDeclaration(ClassDeclaration node) {
|
|
90
|
+
final className = node.name.lexeme;
|
|
91
|
+
_methodBasedResourcesByClass[className] = [];
|
|
92
|
+
_currentClassName = className;
|
|
93
|
+
_disposableResourcesByClass[className] = [];
|
|
94
|
+
|
|
95
|
+
// First: collect external resources from constructor parameters
|
|
96
|
+
final externalResources = <String>{};
|
|
97
|
+
for (final member in node.members) {
|
|
98
|
+
if (member is ConstructorDeclaration) {
|
|
99
|
+
for (final param in member.parameters.parameters) {
|
|
100
|
+
if (param is FieldFormalParameter) {
|
|
101
|
+
// this.controller pattern - external resource
|
|
102
|
+
externalResources.add(param.name.lexeme);
|
|
103
|
+
} else if (param is SimpleFormalParameter) {
|
|
104
|
+
// Check if it's assigned to a field in initializers
|
|
105
|
+
final paramName = param.name?.lexeme;
|
|
106
|
+
if (paramName != null) {
|
|
107
|
+
// Check initializer list for field assignments
|
|
108
|
+
if (member.initializers.any((init) {
|
|
109
|
+
if (init is ConstructorFieldInitializer) {
|
|
110
|
+
return init.expression.toSource().contains(paramName);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
})) {
|
|
114
|
+
externalResources.add(paramName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Second: collect all disposable fields (only owned ones)
|
|
123
|
+
for (final member in node.members) {
|
|
124
|
+
if (member is FieldDeclaration) {
|
|
125
|
+
_visitFieldForDisposableResources(member, externalResources);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Second pass: check if dispose method exists and handles all resources
|
|
130
|
+
MethodDeclaration? disposeMethod;
|
|
131
|
+
for (final member in node.members) {
|
|
132
|
+
if (member is MethodDeclaration && member.name.lexeme == 'dispose') {
|
|
133
|
+
disposeMethod = member;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
final disposableResources = _disposableResourcesByClass[className] ?? [];
|
|
139
|
+
final methodBasedResources = _methodBasedResourcesByClass[className] ?? [];
|
|
140
|
+
final allResources = [...disposableResources, ...methodBasedResources];
|
|
141
|
+
|
|
142
|
+
if (allResources.isNotEmpty) {
|
|
143
|
+
if (disposeMethod == null) {
|
|
144
|
+
// No dispose method found but there are disposable resources
|
|
145
|
+
violations.add(analyzer.createViolation(
|
|
146
|
+
filePath: filePath,
|
|
147
|
+
line: analyzer.getLine(lineInfo, node.name.offset),
|
|
148
|
+
column: analyzer.getColumn(lineInfo, node.name.offset),
|
|
149
|
+
message:
|
|
150
|
+
'Class "$className" has ${allResources.length} owned disposable resource(s) but no dispose() method. '
|
|
151
|
+
'Resources: ${allResources.map((r) => r.name).join(", ")}',
|
|
152
|
+
));
|
|
153
|
+
} else {
|
|
154
|
+
// Check if all resources are disposed
|
|
155
|
+
_checkDisposeMethodCompleteness(
|
|
156
|
+
disposeMethod,
|
|
157
|
+
allResources,
|
|
158
|
+
className,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
super.visitClassDeclaration(node);
|
|
164
|
+
_currentClassName = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
void _visitFieldForDisposableResources(
|
|
168
|
+
FieldDeclaration node,
|
|
169
|
+
Set<String> externalResources,
|
|
170
|
+
) {
|
|
171
|
+
final type = node.fields.type?.toSource() ?? '';
|
|
172
|
+
|
|
173
|
+
for (final variable in node.fields.variables) {
|
|
174
|
+
final variableName = variable.name.lexeme;
|
|
175
|
+
|
|
176
|
+
// Skip external resources (from constructor parameters)
|
|
177
|
+
if (externalResources.contains(variableName)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skip if no initializer (might be late-initialized or constructor-assigned external resource)
|
|
182
|
+
if (variable.initializer == null && node.fields.isLate) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Determine the type to check
|
|
187
|
+
String typeToCheck = type;
|
|
188
|
+
if (typeToCheck.isEmpty && variable.initializer != null) {
|
|
189
|
+
// No explicit type annotation, infer from initializer
|
|
190
|
+
// e.g., final controller = TextEditingController()
|
|
191
|
+
final initializerStr = variable.initializer!.toSource();
|
|
192
|
+
// Extract constructor name (everything before the first '(')
|
|
193
|
+
final match = RegExp(r'(\w+)\s*\(').firstMatch(initializerStr);
|
|
194
|
+
if (match != null) {
|
|
195
|
+
typeToCheck = match.group(1) ?? '';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check if the type requires disposal
|
|
200
|
+
if (_isDisposableType(typeToCheck)) {
|
|
201
|
+
// Only track if it has an initializer (owned resource)
|
|
202
|
+
if (variable.initializer != null) {
|
|
203
|
+
_disposableResourcesByClass[_currentClassName]?.add(
|
|
204
|
+
_DisposableResource(
|
|
205
|
+
name: variableName,
|
|
206
|
+
type: typeToCheck,
|
|
207
|
+
line: analyzer.getLine(lineInfo, variable.name.offset),
|
|
208
|
+
column: analyzer.getColumn(lineInfo, variable.name.offset),
|
|
209
|
+
isOwned: true,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if the initialization calls methods that require cleanup
|
|
216
|
+
if (variable.initializer != null) {
|
|
217
|
+
_checkMethodBasedInitialization(variable);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
void _checkMethodBasedInitialization(VariableDeclaration variable) {
|
|
223
|
+
final initializer = variable.initializer;
|
|
224
|
+
if (initializer == null) return;
|
|
225
|
+
|
|
226
|
+
final initializerSource = initializer.toSource();
|
|
227
|
+
final variableName = variable.name.lexeme;
|
|
228
|
+
|
|
229
|
+
// Check if initialization calls methods requiring cleanup
|
|
230
|
+
for (final entry in D002DisposeResourcesAnalyzer._methodPairsRequiringCleanup.entries) {
|
|
231
|
+
final setupMethod = entry.key;
|
|
232
|
+
|
|
233
|
+
if (initializerSource.contains('.$setupMethod(')) {
|
|
234
|
+
_methodBasedResourcesByClass[_currentClassName]?.add(
|
|
235
|
+
_DisposableResource(
|
|
236
|
+
name: variableName,
|
|
237
|
+
type: 'Method-based ($setupMethod)',
|
|
238
|
+
line: analyzer.getLine(lineInfo, variable.name.offset),
|
|
239
|
+
column: analyzer.getColumn(lineInfo, variable.name.offset),
|
|
240
|
+
requiresMethodCleanup: setupMethod,
|
|
241
|
+
isOwned: true,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
bool _isDisposableType(String type) {
|
|
250
|
+
// Remove generic parameters and null-safety markers
|
|
251
|
+
final cleanType = type.replaceAll(RegExp(r'[<>?].*'), '').trim();
|
|
252
|
+
|
|
253
|
+
// Check exact type matches (known Flutter/Dart types)
|
|
254
|
+
final hasExactMatch = D002DisposeResourcesAnalyzer._disposableTypes.any(
|
|
255
|
+
(disposableType) => cleanType.contains(disposableType),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (hasExactMatch) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check pattern-based suffixes for custom disposable types
|
|
263
|
+
// e.g., CustomController, AppStateDisposable, UserNotifier, etc.
|
|
264
|
+
final hasSuffixMatch = D002DisposeResourcesAnalyzer._disposableSuffixes.any(
|
|
265
|
+
(suffix) => cleanType.endsWith(suffix),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return hasSuffixMatch;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
void _checkDisposeMethodCompleteness(
|
|
272
|
+
MethodDeclaration disposeMethod,
|
|
273
|
+
List<_DisposableResource> resources,
|
|
274
|
+
String className,
|
|
275
|
+
) {
|
|
276
|
+
final disposeBody = disposeMethod.body.toSource();
|
|
277
|
+
final undisposedResources = <_DisposableResource>[];
|
|
278
|
+
|
|
279
|
+
for (final resource in resources) {
|
|
280
|
+
// Check if resource is disposed or cancelled
|
|
281
|
+
final isDisposed = _isResourceDisposed(disposeBody, resource.name);
|
|
282
|
+
|
|
283
|
+
if (!isDisposed) {
|
|
284
|
+
undisposedResources.add(resource);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Report violations for undisposed resources
|
|
289
|
+
for (final resource in undisposedResources) {
|
|
290
|
+
violations.add(analyzer.createViolation(
|
|
291
|
+
filePath: filePath,
|
|
292
|
+
line: resource.line,
|
|
293
|
+
column: resource.column,
|
|
294
|
+
message:
|
|
295
|
+
'Resource "${resource.name}" of type "${resource.type}" is not disposed in dispose() method. '
|
|
296
|
+
'Add "${resource.name}.dispose()" or "${resource.name}.cancel()" in the dispose() method.',
|
|
297
|
+
));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
bool _isResourceDisposed(String disposeBody, String resourceName) {
|
|
302
|
+
// Common disposal patterns
|
|
303
|
+
final disposalPatterns = [
|
|
304
|
+
'$resourceName.dispose()',
|
|
305
|
+
'$resourceName?.dispose()',
|
|
306
|
+
'$resourceName.cancel()',
|
|
307
|
+
'$resourceName?.cancel()',
|
|
308
|
+
'$resourceName.close()',
|
|
309
|
+
'$resourceName?.close()',
|
|
310
|
+
'$resourceName.unsubscribe()',
|
|
311
|
+
'$resourceName?.unsubscribe()',
|
|
312
|
+
'$resourceName.removeListener(',
|
|
313
|
+
'$resourceName?.removeListener(',
|
|
314
|
+
// Check for null assignment (not ideal but acceptable)
|
|
315
|
+
'$resourceName = null',
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
return disposalPatterns.any((pattern) => disposeBody.contains(pattern));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
class _DisposableResource {
|
|
323
|
+
final String name;
|
|
324
|
+
final String type;
|
|
325
|
+
final int line;
|
|
326
|
+
final int column;
|
|
327
|
+
final String? requiresMethodCleanup; // e.g., 'subscribe', 'addListener'
|
|
328
|
+
final bool isOwned; // true if created locally, false if external
|
|
329
|
+
|
|
330
|
+
_DisposableResource({
|
|
331
|
+
required this.name,
|
|
332
|
+
required this.type,
|
|
333
|
+
required this.line,
|
|
334
|
+
required this.column,
|
|
335
|
+
this.requiresMethodCleanup,
|
|
336
|
+
this.isOwned = true,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import 'package:analyzer/dart/ast/ast.dart';
|
|
2
|
+
import 'package:analyzer/dart/ast/visitor.dart';
|
|
3
|
+
import 'package:analyzer/source/line_info.dart';
|
|
4
|
+
|
|
5
|
+
import '../../models/rule.dart';
|
|
6
|
+
import '../../models/violation.dart';
|
|
7
|
+
import '../base_analyzer.dart';
|
|
8
|
+
|
|
9
|
+
/// D003: Prefer Widgets Over Methods Returning Widgets
|
|
10
|
+
/// Recommends extracting methods that return widgets into separate widget classes
|
|
11
|
+
/// for better performance, reusability, and maintainability
|
|
12
|
+
class D003PreferWidgetsOverMethodsAnalyzer extends BaseAnalyzer {
|
|
13
|
+
@override
|
|
14
|
+
String get ruleId => 'D003';
|
|
15
|
+
|
|
16
|
+
// Widget types to check for in return types
|
|
17
|
+
// Includes base types and common Flutter widgets
|
|
18
|
+
static const _widgetTypes = [
|
|
19
|
+
'Widget',
|
|
20
|
+
'StatelessWidget',
|
|
21
|
+
'StatefulWidget',
|
|
22
|
+
'PreferredSizeWidget',
|
|
23
|
+
'InheritedWidget',
|
|
24
|
+
'Container',
|
|
25
|
+
'Text',
|
|
26
|
+
'Column',
|
|
27
|
+
'Row',
|
|
28
|
+
'Stack',
|
|
29
|
+
'Scaffold',
|
|
30
|
+
'AppBar',
|
|
31
|
+
'ListView',
|
|
32
|
+
'GridView',
|
|
33
|
+
'Card',
|
|
34
|
+
'Padding',
|
|
35
|
+
'Center',
|
|
36
|
+
'SizedBox',
|
|
37
|
+
'Expanded',
|
|
38
|
+
'Flexible',
|
|
39
|
+
'CustomPaint',
|
|
40
|
+
'CustomScrollView',
|
|
41
|
+
'Align',
|
|
42
|
+
'AspectRatio',
|
|
43
|
+
'Baseline',
|
|
44
|
+
'ConstrainedBox',
|
|
45
|
+
'FittedBox',
|
|
46
|
+
'FractionallySizedBox',
|
|
47
|
+
'LimitedBox',
|
|
48
|
+
'Offstage',
|
|
49
|
+
'OverflowBox',
|
|
50
|
+
'SizedOverflowBox',
|
|
51
|
+
'Transform',
|
|
52
|
+
'Wrap',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
List<Violation> analyze({
|
|
57
|
+
required CompilationUnit unit,
|
|
58
|
+
required String filePath,
|
|
59
|
+
required Rule rule,
|
|
60
|
+
required LineInfo lineInfo,
|
|
61
|
+
}) {
|
|
62
|
+
final violations = <Violation>[];
|
|
63
|
+
|
|
64
|
+
final visitor = _D003Visitor(
|
|
65
|
+
filePath: filePath,
|
|
66
|
+
lineInfo: lineInfo,
|
|
67
|
+
violations: violations,
|
|
68
|
+
analyzer: this,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
unit.accept(visitor);
|
|
72
|
+
|
|
73
|
+
return violations;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class _D003Visitor extends RecursiveAstVisitor<void> {
|
|
78
|
+
final String filePath;
|
|
79
|
+
final LineInfo lineInfo;
|
|
80
|
+
final List<Violation> violations;
|
|
81
|
+
final D003PreferWidgetsOverMethodsAnalyzer analyzer;
|
|
82
|
+
|
|
83
|
+
String? _currentClassName;
|
|
84
|
+
bool _insideWidgetClass = false;
|
|
85
|
+
|
|
86
|
+
_D003Visitor({
|
|
87
|
+
required this.filePath,
|
|
88
|
+
required this.lineInfo,
|
|
89
|
+
required this.violations,
|
|
90
|
+
required this.analyzer,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
@override
|
|
94
|
+
void visitClassDeclaration(ClassDeclaration node) {
|
|
95
|
+
final previousClassName = _currentClassName;
|
|
96
|
+
final previousInsideWidgetClass = _insideWidgetClass;
|
|
97
|
+
|
|
98
|
+
_currentClassName = node.name.lexeme;
|
|
99
|
+
|
|
100
|
+
// Check if this class extends a Widget
|
|
101
|
+
final extendsClause = node.extendsClause;
|
|
102
|
+
if (extendsClause != null) {
|
|
103
|
+
final superclass = extendsClause.superclass.name2.lexeme;
|
|
104
|
+
_insideWidgetClass = superclass == 'StatelessWidget' ||
|
|
105
|
+
superclass == 'StatefulWidget' ||
|
|
106
|
+
superclass.contains('Widget');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
super.visitClassDeclaration(node);
|
|
110
|
+
|
|
111
|
+
_currentClassName = previousClassName;
|
|
112
|
+
_insideWidgetClass = previousInsideWidgetClass;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@override
|
|
116
|
+
void visitMethodDeclaration(MethodDeclaration node) {
|
|
117
|
+
// Only check methods inside widget classes
|
|
118
|
+
if (!_insideWidgetClass) {
|
|
119
|
+
super.visitMethodDeclaration(node);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Skip the build method itself
|
|
124
|
+
final methodName = node.name.lexeme;
|
|
125
|
+
if (methodName == 'build') {
|
|
126
|
+
super.visitMethodDeclaration(node);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Skip lifecycle methods
|
|
131
|
+
if (_isLifecycleMethod(methodName)) {
|
|
132
|
+
super.visitMethodDeclaration(node);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check if method returns a Widget type
|
|
137
|
+
final returnType = node.returnType?.toSource();
|
|
138
|
+
if (returnType == null) {
|
|
139
|
+
super.visitMethodDeclaration(node);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (_isWidgetType(returnType)) {
|
|
144
|
+
// This is a method returning a widget - flag it
|
|
145
|
+
final isPrivate = methodName.startsWith('_');
|
|
146
|
+
final suggestion = isPrivate
|
|
147
|
+
? 'Extract "$methodName" into a private StatelessWidget class'
|
|
148
|
+
: 'Extract "$methodName" into a StatelessWidget class';
|
|
149
|
+
|
|
150
|
+
violations.add(analyzer.createViolation(
|
|
151
|
+
filePath: filePath,
|
|
152
|
+
line: analyzer.getLine(lineInfo, node.name.offset),
|
|
153
|
+
column: analyzer.getColumn(lineInfo, node.name.offset),
|
|
154
|
+
message:
|
|
155
|
+
'Method "$methodName" returns a widget. $suggestion for better performance and maintainability. '
|
|
156
|
+
'Widget rebuilds will be optimized, and the code will be more reusable.',
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
super.visitMethodDeclaration(node);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@override
|
|
164
|
+
void visitNamedExpression(NamedExpression node) {
|
|
165
|
+
// Check for method calls in child: or children: parameters
|
|
166
|
+
final paramName = node.name.label.name;
|
|
167
|
+
|
|
168
|
+
if (paramName == 'child' || paramName == 'children') {
|
|
169
|
+
// Check if the value is a method invocation
|
|
170
|
+
final expression = node.expression;
|
|
171
|
+
|
|
172
|
+
if (expression is MethodInvocation) {
|
|
173
|
+
final methodName = expression.methodName.name;
|
|
174
|
+
|
|
175
|
+
// Flag methods that look like they build widgets
|
|
176
|
+
// Common patterns: _build*, build*, _create*, create*, _get*Widget
|
|
177
|
+
if (_looksLikeWidgetBuilderMethod(methodName)) {
|
|
178
|
+
violations.add(analyzer.createViolation(
|
|
179
|
+
filePath: filePath,
|
|
180
|
+
line: analyzer.getLine(lineInfo, expression.methodName.offset),
|
|
181
|
+
column: analyzer.getColumn(lineInfo, expression.methodName.offset),
|
|
182
|
+
message:
|
|
183
|
+
'Method call "$methodName()" in $paramName: parameter. '
|
|
184
|
+
'Extract this into a proper widget class for better performance. '
|
|
185
|
+
'Widget methods called in child/children parameters prevent optimization.',
|
|
186
|
+
));
|
|
187
|
+
}
|
|
188
|
+
} else if (expression is ListLiteral) {
|
|
189
|
+
// Check for method calls inside children: list
|
|
190
|
+
for (final element in expression.elements) {
|
|
191
|
+
if (element is MethodInvocation) {
|
|
192
|
+
final methodName = element.methodName.name;
|
|
193
|
+
if (_looksLikeWidgetBuilderMethod(methodName)) {
|
|
194
|
+
violations.add(analyzer.createViolation(
|
|
195
|
+
filePath: filePath,
|
|
196
|
+
line: analyzer.getLine(lineInfo, element.methodName.offset),
|
|
197
|
+
column: analyzer.getColumn(lineInfo, element.methodName.offset),
|
|
198
|
+
message:
|
|
199
|
+
'Method call "$methodName()" in children: list. '
|
|
200
|
+
'Extract this into a proper widget class for better performance.',
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
super.visitNamedExpression(node);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
bool _looksLikeWidgetBuilderMethod(String methodName) {
|
|
212
|
+
// Check if method name follows common widget builder patterns
|
|
213
|
+
return methodName.startsWith('_build') ||
|
|
214
|
+
methodName.startsWith('build') ||
|
|
215
|
+
methodName.startsWith('_create') ||
|
|
216
|
+
methodName.startsWith('create') ||
|
|
217
|
+
methodName.startsWith('_get') && methodName.contains('Widget') ||
|
|
218
|
+
methodName.startsWith('get') && methodName.contains('Widget');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
bool _isWidgetType(String type) {
|
|
222
|
+
// Remove generics and null-safety markers
|
|
223
|
+
final cleanType = type.replaceAll(RegExp(r'[<>?].*'), '').trim();
|
|
224
|
+
|
|
225
|
+
// Strategy 1: Check if it's a known widget type
|
|
226
|
+
final isKnownWidget = D003PreferWidgetsOverMethodsAnalyzer._widgetTypes
|
|
227
|
+
.any((widgetType) => cleanType == widgetType || cleanType.endsWith(widgetType));
|
|
228
|
+
|
|
229
|
+
if (isKnownWidget) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Strategy 2: Check if it ends with 'Widget' (catches custom widgets)
|
|
234
|
+
// e.g., CustomWidget, UserProfileWidget, MyAppWidget
|
|
235
|
+
if (cleanType.endsWith('Widget')) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Strategy 3: Check if it ends with common widget suffixes
|
|
240
|
+
// e.g., CustomPainter, CustomClipper, CustomCard, CustomButton
|
|
241
|
+
const customWidgetSuffixes = [
|
|
242
|
+
'Painter', // CustomPainter
|
|
243
|
+
'Clipper', // CustomClipper
|
|
244
|
+
'Card', // CustomCard
|
|
245
|
+
'Button', // CustomButton
|
|
246
|
+
'Dialog', // CustomDialog
|
|
247
|
+
'Sheet', // CustomSheet
|
|
248
|
+
'Bar', // CustomBar, TabBar
|
|
249
|
+
'View', // CustomView, GridView
|
|
250
|
+
'List', // CustomList
|
|
251
|
+
'Item', // ListItem, GridItem
|
|
252
|
+
'Tile', // ListTile, CustomTile
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
return customWidgetSuffixes.any((suffix) => cleanType.endsWith(suffix));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
bool _isLifecycleMethod(String methodName) {
|
|
259
|
+
// Skip Flutter lifecycle methods
|
|
260
|
+
const lifecycleMethods = [
|
|
261
|
+
'initState',
|
|
262
|
+
'dispose',
|
|
263
|
+
'didChangeDependencies',
|
|
264
|
+
'didUpdateWidget',
|
|
265
|
+
'reassemble',
|
|
266
|
+
'deactivate',
|
|
267
|
+
'setState',
|
|
268
|
+
'createState',
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
return lifecycleMethods.contains(methodName);
|
|
272
|
+
}
|
|
273
|
+
}
|