@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,246 @@
|
|
|
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
|
+
/// D013: Prefer a Single Public Class Per File
|
|
10
|
+
/// Each file should contain only one public class to improve code organization and maintainability
|
|
11
|
+
///
|
|
12
|
+
/// Exceptions:
|
|
13
|
+
/// - Sealed classes / union types with small implementations (<20 lines each)
|
|
14
|
+
/// - Multiple small classes (<20 lines) that share a common base class
|
|
15
|
+
class D013SinglePublicClassAnalyzer extends BaseAnalyzer {
|
|
16
|
+
@override
|
|
17
|
+
String get ruleId => 'D013';
|
|
18
|
+
|
|
19
|
+
// Maximum lines for a "small" class in a sealed/union pattern
|
|
20
|
+
static const int _maxLinesForSmallClass = 20;
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
List<Violation> analyze({
|
|
24
|
+
required CompilationUnit unit,
|
|
25
|
+
required String filePath,
|
|
26
|
+
required Rule rule,
|
|
27
|
+
required LineInfo lineInfo,
|
|
28
|
+
}) {
|
|
29
|
+
final violations = <Violation>[];
|
|
30
|
+
|
|
31
|
+
final visitor = _D013Visitor(
|
|
32
|
+
filePath: filePath,
|
|
33
|
+
lineInfo: lineInfo,
|
|
34
|
+
violations: violations,
|
|
35
|
+
analyzer: this,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
unit.accept(visitor);
|
|
39
|
+
|
|
40
|
+
return violations;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class _D013Visitor extends RecursiveAstVisitor<void> {
|
|
45
|
+
final String filePath;
|
|
46
|
+
final LineInfo lineInfo;
|
|
47
|
+
final List<Violation> violations;
|
|
48
|
+
final D013SinglePublicClassAnalyzer analyzer;
|
|
49
|
+
|
|
50
|
+
final List<ClassDeclaration> _publicClasses = [];
|
|
51
|
+
final Map<String, _ClassInfo> _classInfoMap = {};
|
|
52
|
+
|
|
53
|
+
_D013Visitor({
|
|
54
|
+
required this.filePath,
|
|
55
|
+
required this.lineInfo,
|
|
56
|
+
required this.violations,
|
|
57
|
+
required this.analyzer,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
void visitCompilationUnit(CompilationUnit node) {
|
|
62
|
+
// First pass: collect all public classes and their metadata
|
|
63
|
+
_publicClasses.clear();
|
|
64
|
+
_classInfoMap.clear();
|
|
65
|
+
super.visitCompilationUnit(node);
|
|
66
|
+
|
|
67
|
+
// Second pass: analyze if multiple public classes are acceptable
|
|
68
|
+
if (_publicClasses.length > 1) {
|
|
69
|
+
// Check for Flutter Widget + public State pattern
|
|
70
|
+
final widgetStateIssue = _checkFlutterWidgetStatePattern();
|
|
71
|
+
if (widgetStateIssue != null) {
|
|
72
|
+
violations.add(widgetStateIssue);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!_isAcceptableMultipleClasses()) {
|
|
77
|
+
final firstClass = _publicClasses[0];
|
|
78
|
+
final classNames = _publicClasses.map((c) => c.name.lexeme).join(', ');
|
|
79
|
+
|
|
80
|
+
violations.add(analyzer.createViolation(
|
|
81
|
+
filePath: filePath,
|
|
82
|
+
line: analyzer.getLine(lineInfo, firstClass.name.offset),
|
|
83
|
+
column: analyzer.getColumn(lineInfo, firstClass.name.offset),
|
|
84
|
+
message:
|
|
85
|
+
'File contains ${_publicClasses.length} public classes: $classNames. Consider moving them to separate files (one public class per file).',
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Check for Flutter Widget with public State class pattern
|
|
92
|
+
/// Returns a violation if found, null otherwise
|
|
93
|
+
Violation? _checkFlutterWidgetStatePattern() {
|
|
94
|
+
if (_publicClasses.length != 2) return null;
|
|
95
|
+
|
|
96
|
+
ClassDeclaration? widgetClass;
|
|
97
|
+
ClassDeclaration? stateClass;
|
|
98
|
+
|
|
99
|
+
for (final cls in _publicClasses) {
|
|
100
|
+
final info = _classInfoMap[cls.name.lexeme];
|
|
101
|
+
if (info == null) continue;
|
|
102
|
+
|
|
103
|
+
if (info.baseClassName == 'StatefulWidget') {
|
|
104
|
+
widgetClass = cls;
|
|
105
|
+
} else if (info.baseClassName == 'State') {
|
|
106
|
+
stateClass = cls;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If we have a StatefulWidget and a public State class
|
|
111
|
+
if (widgetClass != null && stateClass != null) {
|
|
112
|
+
final widgetName = widgetClass.name.lexeme;
|
|
113
|
+
final stateName = stateClass.name.lexeme;
|
|
114
|
+
|
|
115
|
+
// Check if State class name follows the pattern: WidgetNameState
|
|
116
|
+
// This confirms they're related
|
|
117
|
+
if (stateName.endsWith('State') &&
|
|
118
|
+
(stateName == '${widgetName}State' ||
|
|
119
|
+
stateName.startsWith(widgetName))) {
|
|
120
|
+
return analyzer.createViolation(
|
|
121
|
+
filePath: filePath,
|
|
122
|
+
line: analyzer.getLine(lineInfo, stateClass.name.offset),
|
|
123
|
+
column: analyzer.getColumn(lineInfo, stateClass.name.offset),
|
|
124
|
+
message:
|
|
125
|
+
'State class "$stateName" should be private (prefix with underscore: _$stateName). Public State classes expose internal implementation details.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Check if multiple public classes are acceptable (sealed class pattern)
|
|
134
|
+
bool _isAcceptableMultipleClasses() {
|
|
135
|
+
// Find sealed classes or potential base classes
|
|
136
|
+
final sealedClasses = <String>[];
|
|
137
|
+
final baseClasses = <String>{};
|
|
138
|
+
|
|
139
|
+
for (final cls in _publicClasses) {
|
|
140
|
+
final className = cls.name.lexeme;
|
|
141
|
+
final info = _classInfoMap[className];
|
|
142
|
+
if (info == null) continue;
|
|
143
|
+
|
|
144
|
+
// Check if it's a sealed class (has 'sealed' modifier)
|
|
145
|
+
if (info.isSealed) {
|
|
146
|
+
sealedClasses.add(className);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if other classes extend/implement this class
|
|
150
|
+
final childCount = _publicClasses.where((c) {
|
|
151
|
+
final childInfo = _classInfoMap[c.name.lexeme];
|
|
152
|
+
return childInfo != null && childInfo.baseClassName == className;
|
|
153
|
+
}).length;
|
|
154
|
+
|
|
155
|
+
if (childCount > 0) {
|
|
156
|
+
baseClasses.add(className);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// If we have sealed/base classes, check if all child classes are small
|
|
161
|
+
if (sealedClasses.isNotEmpty || baseClasses.isNotEmpty) {
|
|
162
|
+
final allBasesAndChildren = <String>{...sealedClasses, ...baseClasses};
|
|
163
|
+
|
|
164
|
+
// Get all classes that extend a base class
|
|
165
|
+
final childClasses = _publicClasses.where((cls) {
|
|
166
|
+
final info = _classInfoMap[cls.name.lexeme];
|
|
167
|
+
return info != null &&
|
|
168
|
+
info.baseClassName != null &&
|
|
169
|
+
allBasesAndChildren.contains(info.baseClassName);
|
|
170
|
+
}).toList();
|
|
171
|
+
|
|
172
|
+
// Check if there are any standalone classes (not base, not child)
|
|
173
|
+
final standaloneClasses = _publicClasses.where((cls) {
|
|
174
|
+
final className = cls.name.lexeme;
|
|
175
|
+
final info = _classInfoMap[className];
|
|
176
|
+
return !allBasesAndChildren.contains(className) &&
|
|
177
|
+
(info?.baseClassName == null ||
|
|
178
|
+
!allBasesAndChildren.contains(info?.baseClassName));
|
|
179
|
+
}).toList();
|
|
180
|
+
|
|
181
|
+
// Allow if:
|
|
182
|
+
// 1. All child classes are small (<20 lines)
|
|
183
|
+
// 2. There's at most one standalone class (the main class of the file)
|
|
184
|
+
final allChildrenSmall = childClasses.every((cls) {
|
|
185
|
+
final info = _classInfoMap[cls.name.lexeme];
|
|
186
|
+
return info != null &&
|
|
187
|
+
info.lineCount <=
|
|
188
|
+
D013SinglePublicClassAnalyzer._maxLinesForSmallClass;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return allChildrenSmall && standaloneClasses.length <= 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@override
|
|
198
|
+
void visitClassDeclaration(ClassDeclaration node) {
|
|
199
|
+
final className = node.name.lexeme;
|
|
200
|
+
|
|
201
|
+
// Check if class is public (doesn't start with underscore)
|
|
202
|
+
if (!className.startsWith('_')) {
|
|
203
|
+
_publicClasses.add(node);
|
|
204
|
+
|
|
205
|
+
// Collect metadata about the class
|
|
206
|
+
final startLine = lineInfo.getLocation(node.offset).lineNumber;
|
|
207
|
+
final endLine = lineInfo.getLocation(node.end).lineNumber;
|
|
208
|
+
final lineCount = endLine - startLine + 1;
|
|
209
|
+
|
|
210
|
+
// Check if it's a sealed class
|
|
211
|
+
final isSealed = node.sealedKeyword != null;
|
|
212
|
+
|
|
213
|
+
// Get base class name if any
|
|
214
|
+
String? baseClassName;
|
|
215
|
+
if (node.extendsClause != null) {
|
|
216
|
+
baseClassName = node.extendsClause!.superclass.name2.lexeme;
|
|
217
|
+
} else if (node.implementsClause != null &&
|
|
218
|
+
node.implementsClause!.interfaces.isNotEmpty) {
|
|
219
|
+
baseClassName = node.implementsClause!.interfaces.first.name2.lexeme;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_classInfoMap[className] = _ClassInfo(
|
|
223
|
+
className: className,
|
|
224
|
+
lineCount: lineCount,
|
|
225
|
+
isSealed: isSealed,
|
|
226
|
+
baseClassName: baseClassName,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
super.visitClassDeclaration(node);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
class _ClassInfo {
|
|
235
|
+
final String className;
|
|
236
|
+
final int lineCount;
|
|
237
|
+
final bool isSealed;
|
|
238
|
+
final String? baseClassName;
|
|
239
|
+
|
|
240
|
+
_ClassInfo({
|
|
241
|
+
required this.className,
|
|
242
|
+
required this.lineCount,
|
|
243
|
+
required this.isSealed,
|
|
244
|
+
this.baseClassName,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
/// D014: Avoid Unsafe Collection Access
|
|
10
|
+
/// Always check collection empty or length before using first, last, single, or elementAt
|
|
11
|
+
class D014UnsafeCollectionAccessAnalyzer extends BaseAnalyzer {
|
|
12
|
+
@override
|
|
13
|
+
String get ruleId => 'D014';
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
List<Violation> analyze({
|
|
17
|
+
required CompilationUnit unit,
|
|
18
|
+
required String filePath,
|
|
19
|
+
required Rule rule,
|
|
20
|
+
required LineInfo lineInfo,
|
|
21
|
+
}) {
|
|
22
|
+
final violations = <Violation>[];
|
|
23
|
+
|
|
24
|
+
final visitor = _D014Visitor(
|
|
25
|
+
filePath: filePath,
|
|
26
|
+
lineInfo: lineInfo,
|
|
27
|
+
violations: violations,
|
|
28
|
+
analyzer: this,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
unit.accept(visitor);
|
|
32
|
+
|
|
33
|
+
return violations;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class _D014Visitor extends RecursiveAstVisitor<void> {
|
|
38
|
+
final String filePath;
|
|
39
|
+
final LineInfo lineInfo;
|
|
40
|
+
final List<Violation> violations;
|
|
41
|
+
final D014UnsafeCollectionAccessAnalyzer analyzer;
|
|
42
|
+
|
|
43
|
+
// Unsafe collection access properties
|
|
44
|
+
static const _unsafeProperties = ['first', 'last', 'single'];
|
|
45
|
+
|
|
46
|
+
// Unsafe collection access methods
|
|
47
|
+
static const _unsafeMethods = ['firstWhere', 'lastWhere', 'singleWhere', 'elementAt'];
|
|
48
|
+
|
|
49
|
+
// Safe alternatives
|
|
50
|
+
static const _safeAlternatives = {
|
|
51
|
+
'first': 'firstOrNull',
|
|
52
|
+
'last': 'lastOrNull',
|
|
53
|
+
'single': 'singleOrNull',
|
|
54
|
+
'firstWhere': 'orElse parameter',
|
|
55
|
+
'lastWhere': 'orElse parameter',
|
|
56
|
+
'singleWhere': 'orElse parameter',
|
|
57
|
+
'elementAt': 'length check',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
_D014Visitor({
|
|
61
|
+
required this.filePath,
|
|
62
|
+
required this.lineInfo,
|
|
63
|
+
required this.violations,
|
|
64
|
+
required this.analyzer,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
void visitPropertyAccess(PropertyAccess node) {
|
|
69
|
+
final propertyName = node.propertyName.name;
|
|
70
|
+
|
|
71
|
+
// Check if it's one of the unsafe properties
|
|
72
|
+
if (_unsafeProperties.contains(propertyName)) {
|
|
73
|
+
// Skip if using null-aware operator (?.)
|
|
74
|
+
if (node.operator.type.toString() == '?.') {
|
|
75
|
+
super.visitPropertyAccess(node);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if it's protected by a safety check
|
|
80
|
+
if (!_isProtectedByCheck(node)) {
|
|
81
|
+
final alternative = _safeAlternatives[propertyName] ?? 'safe alternative';
|
|
82
|
+
violations.add(analyzer.createViolation(
|
|
83
|
+
filePath: filePath,
|
|
84
|
+
line: analyzer.getLine(lineInfo, node.propertyName.offset),
|
|
85
|
+
column: analyzer.getColumn(lineInfo, node.propertyName.offset),
|
|
86
|
+
message:
|
|
87
|
+
'Unsafe collection access ".$propertyName" may throw if collection is empty. Consider checking isEmpty/length first or use ".$alternative" from collection package.',
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
super.visitPropertyAccess(node);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
void visitMethodInvocation(MethodInvocation node) {
|
|
97
|
+
final methodName = node.methodName.name;
|
|
98
|
+
|
|
99
|
+
// Check for unsafe methods like elementAt, firstWhere, lastWhere, singleWhere
|
|
100
|
+
if (_unsafeMethods.contains(methodName)) {
|
|
101
|
+
// For firstWhere, lastWhere, singleWhere - check if orElse parameter is provided
|
|
102
|
+
if (['firstWhere', 'lastWhere', 'singleWhere'].contains(methodName)) {
|
|
103
|
+
// Check if orElse parameter is provided
|
|
104
|
+
final hasOrElse = node.argumentList.arguments.any((arg) {
|
|
105
|
+
if (arg is NamedExpression) {
|
|
106
|
+
return arg.name.label.name == 'orElse';
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (hasOrElse) {
|
|
112
|
+
super.visitMethodInvocation(node);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!_isProtectedByCheck(node)) {
|
|
118
|
+
String message;
|
|
119
|
+
if (methodName == 'elementAt') {
|
|
120
|
+
message = 'Unsafe collection access ".$methodName()" may throw if index is out of bounds. Consider checking length first.';
|
|
121
|
+
} else {
|
|
122
|
+
message = 'Unsafe collection access ".$methodName()" may throw if element not found. Consider providing an "orElse" parameter.';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
violations.add(analyzer.createViolation(
|
|
126
|
+
filePath: filePath,
|
|
127
|
+
line: analyzer.getLine(lineInfo, node.methodName.offset),
|
|
128
|
+
column: analyzer.getColumn(lineInfo, node.methodName.offset),
|
|
129
|
+
message: message,
|
|
130
|
+
));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
super.visitMethodInvocation(node);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@override
|
|
138
|
+
void visitIndexExpression(IndexExpression node) {
|
|
139
|
+
// Check if target is a Map (skip Maps as per spec)
|
|
140
|
+
final targetType = node.target?.staticType?.toString() ?? '';
|
|
141
|
+
|
|
142
|
+
// Skip if it's a Map type
|
|
143
|
+
if (targetType.contains('Map<') || targetType == 'Map') {
|
|
144
|
+
super.visitIndexExpression(node);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check if it's on a collection (List, Iterable, etc.)
|
|
149
|
+
if (targetType.contains('List<') ||
|
|
150
|
+
targetType.contains('Iterable<') ||
|
|
151
|
+
targetType == 'List' ||
|
|
152
|
+
targetType == 'Iterable') {
|
|
153
|
+
if (!_isProtectedByCheck(node)) {
|
|
154
|
+
violations.add(analyzer.createViolation(
|
|
155
|
+
filePath: filePath,
|
|
156
|
+
line: analyzer.getLine(lineInfo, node.leftBracket.offset),
|
|
157
|
+
column: analyzer.getColumn(lineInfo, node.leftBracket.offset),
|
|
158
|
+
message:
|
|
159
|
+
'Unsafe collection access using [] operator may throw if index is out of bounds. Consider checking length first or use "elementAtOrNull" from collection package.',
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
super.visitIndexExpression(node);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Check if the collection access is protected by a safety check
|
|
168
|
+
bool _isProtectedByCheck(AstNode node) {
|
|
169
|
+
AstNode? current = node.parent;
|
|
170
|
+
|
|
171
|
+
while (current != null) {
|
|
172
|
+
// Check if inside an if statement with safety check
|
|
173
|
+
if (current is IfStatement) {
|
|
174
|
+
final condition = current.expression;
|
|
175
|
+
if (_containsSafetyCheck(condition)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check if inside a conditional expression with safety check
|
|
181
|
+
if (current is ConditionalExpression) {
|
|
182
|
+
if (_containsSafetyCheck(current.condition)) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
current = current.parent;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Check if an expression contains safety checks like isEmpty, isNotEmpty, length > 0
|
|
194
|
+
bool _containsSafetyCheck(Expression expr) {
|
|
195
|
+
final exprSource = expr.toString();
|
|
196
|
+
|
|
197
|
+
// Check for common safety patterns
|
|
198
|
+
return exprSource.contains('isEmpty') ||
|
|
199
|
+
exprSource.contains('isNotEmpty') ||
|
|
200
|
+
exprSource.contains('.length');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
/// D015: Ensure copyWith includes all constructor parameters
|
|
10
|
+
/// When a class has a copyWith method, it should include all constructor parameters
|
|
11
|
+
class D015CopyWithAllParametersAnalyzer extends BaseAnalyzer {
|
|
12
|
+
@override
|
|
13
|
+
String get ruleId => 'D015';
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
List<Violation> analyze({
|
|
17
|
+
required CompilationUnit unit,
|
|
18
|
+
required String filePath,
|
|
19
|
+
required Rule rule,
|
|
20
|
+
required LineInfo lineInfo,
|
|
21
|
+
}) {
|
|
22
|
+
final violations = <Violation>[];
|
|
23
|
+
|
|
24
|
+
final visitor = _D015Visitor(
|
|
25
|
+
filePath: filePath,
|
|
26
|
+
lineInfo: lineInfo,
|
|
27
|
+
violations: violations,
|
|
28
|
+
analyzer: this,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
unit.accept(visitor);
|
|
32
|
+
|
|
33
|
+
return violations;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class _D015Visitor extends RecursiveAstVisitor<void> {
|
|
38
|
+
final String filePath;
|
|
39
|
+
final LineInfo lineInfo;
|
|
40
|
+
final List<Violation> violations;
|
|
41
|
+
final D015CopyWithAllParametersAnalyzer analyzer;
|
|
42
|
+
|
|
43
|
+
// Track classes being analyzed
|
|
44
|
+
ClassDeclaration? _currentClass;
|
|
45
|
+
final Map<String, Set<String>> _classConstructorParams = {};
|
|
46
|
+
final Map<String, Set<String>> _classCopyWithParams = {};
|
|
47
|
+
final Map<String, MethodDeclaration> _classCopyWithMethods = {};
|
|
48
|
+
|
|
49
|
+
_D015Visitor({
|
|
50
|
+
required this.filePath,
|
|
51
|
+
required this.lineInfo,
|
|
52
|
+
required this.violations,
|
|
53
|
+
required this.analyzer,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
void visitClassDeclaration(ClassDeclaration node) {
|
|
58
|
+
_currentClass = node;
|
|
59
|
+
final className = node.name.lexeme;
|
|
60
|
+
|
|
61
|
+
// Collect constructor parameters
|
|
62
|
+
final constructorParams = <String>{};
|
|
63
|
+
for (final member in node.members) {
|
|
64
|
+
if (member is ConstructorDeclaration && member.name == null) {
|
|
65
|
+
// Default constructor
|
|
66
|
+
for (final param in member.parameters.parameters) {
|
|
67
|
+
if (param is DefaultFormalParameter) {
|
|
68
|
+
constructorParams.add(param.parameter.name?.lexeme ?? '');
|
|
69
|
+
} else if (param is SimpleFormalParameter) {
|
|
70
|
+
constructorParams.add(param.name?.lexeme ?? '');
|
|
71
|
+
} else if (param is FieldFormalParameter) {
|
|
72
|
+
constructorParams.add(param.name.lexeme);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_classConstructorParams[className] = constructorParams;
|
|
79
|
+
|
|
80
|
+
super.visitClassDeclaration(node);
|
|
81
|
+
|
|
82
|
+
// After visiting all members, check if copyWith exists and compare
|
|
83
|
+
if (_classCopyWithMethods.containsKey(className)) {
|
|
84
|
+
final copyWithParams = _classCopyWithParams[className] ?? {};
|
|
85
|
+
final missingParams = constructorParams.difference(copyWithParams);
|
|
86
|
+
|
|
87
|
+
if (missingParams.isNotEmpty) {
|
|
88
|
+
final copyWithMethod = _classCopyWithMethods[className]!;
|
|
89
|
+
violations.add(analyzer.createViolation(
|
|
90
|
+
filePath: filePath,
|
|
91
|
+
line: analyzer.getLine(lineInfo, copyWithMethod.name.offset),
|
|
92
|
+
column: analyzer.getColumn(lineInfo, copyWithMethod.name.offset),
|
|
93
|
+
message:
|
|
94
|
+
'copyWith method in class "$className" is missing parameters: ${missingParams.join(', ')}. All constructor parameters should be included in copyWith.',
|
|
95
|
+
));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_currentClass = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
void visitMethodDeclaration(MethodDeclaration node) {
|
|
104
|
+
if (_currentClass != null && node.name.lexeme == 'copyWith') {
|
|
105
|
+
final className = _currentClass!.name.lexeme;
|
|
106
|
+
_classCopyWithMethods[className] = node;
|
|
107
|
+
|
|
108
|
+
// Collect copyWith parameters
|
|
109
|
+
final copyWithParams = <String>{};
|
|
110
|
+
if (node.parameters != null) {
|
|
111
|
+
for (final param in node.parameters!.parameters) {
|
|
112
|
+
if (param is DefaultFormalParameter) {
|
|
113
|
+
copyWithParams.add(param.parameter.name?.lexeme ?? '');
|
|
114
|
+
} else if (param is SimpleFormalParameter) {
|
|
115
|
+
copyWithParams.add(param.name?.lexeme ?? '');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_classCopyWithParams[className] = copyWithParams;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
super.visitMethodDeclaration(node);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import 'dart:io';
|
|
2
|
+
|
|
3
|
+
import 'package:analyzer/dart/ast/ast.dart';
|
|
4
|
+
import 'package:analyzer/source/line_info.dart';
|
|
5
|
+
import 'package:path/path.dart' as path;
|
|
6
|
+
|
|
7
|
+
import '../../models/rule.dart';
|
|
8
|
+
import '../../models/violation.dart';
|
|
9
|
+
import '../base_analyzer.dart';
|
|
10
|
+
|
|
11
|
+
/// D016: Project Should Have Tests
|
|
12
|
+
/// Ensures the project has a test directory with test files
|
|
13
|
+
class D016ProjectShouldHaveTestsAnalyzer extends BaseAnalyzer {
|
|
14
|
+
@override
|
|
15
|
+
String get ruleId => 'D016';
|
|
16
|
+
|
|
17
|
+
// Default test directories to look for
|
|
18
|
+
static const _defaultTestDirectories = ['test', 'integration_test'];
|
|
19
|
+
|
|
20
|
+
// Test file pattern
|
|
21
|
+
static final _testFilePattern = RegExp(r'_test\.dart$');
|
|
22
|
+
|
|
23
|
+
// Track which projects we've already checked to avoid duplicate checks
|
|
24
|
+
static final Set<String> _checkedProjects = {};
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
List<Violation> analyze({
|
|
28
|
+
required CompilationUnit unit,
|
|
29
|
+
required String filePath,
|
|
30
|
+
required Rule rule,
|
|
31
|
+
required LineInfo lineInfo,
|
|
32
|
+
}) {
|
|
33
|
+
final violations = <Violation>[];
|
|
34
|
+
|
|
35
|
+
// This rule checks for test directory existence, not individual Dart files
|
|
36
|
+
// We only need to check once per project
|
|
37
|
+
final projectRoot = _findProjectRoot(filePath);
|
|
38
|
+
if (projectRoot == null) {
|
|
39
|
+
return violations;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Performance optimization: Skip if we've already checked this project
|
|
43
|
+
if (_checkedProjects.contains(projectRoot)) {
|
|
44
|
+
return violations;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mark as checked immediately
|
|
48
|
+
_checkedProjects.add(projectRoot);
|
|
49
|
+
|
|
50
|
+
// Get test directories from config, default to ['test', 'integration_test']
|
|
51
|
+
final testDirectories = (rule.config['testDirectories'] as List?)
|
|
52
|
+
?.map((e) => e.toString())
|
|
53
|
+
.toList() ??
|
|
54
|
+
_defaultTestDirectories;
|
|
55
|
+
|
|
56
|
+
// Get minimum required test files from config, default to 1
|
|
57
|
+
final minTestFiles = (rule.config['minTestFiles'] as int?) ?? 1;
|
|
58
|
+
|
|
59
|
+
// Check if any test directory exists with test files
|
|
60
|
+
bool foundTestDirectory = false;
|
|
61
|
+
int totalTestFiles = 0;
|
|
62
|
+
|
|
63
|
+
for (final testDir in testDirectories) {
|
|
64
|
+
final testDirPath = path.join(projectRoot, testDir);
|
|
65
|
+
final testDirectory = Directory(testDirPath);
|
|
66
|
+
|
|
67
|
+
if (testDirectory.existsSync()) {
|
|
68
|
+
foundTestDirectory = true;
|
|
69
|
+
|
|
70
|
+
// Count test files in this directory
|
|
71
|
+
final testFiles = _findTestFiles(testDirectory);
|
|
72
|
+
totalTestFiles += testFiles.length;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Report violations
|
|
77
|
+
if (!foundTestDirectory) {
|
|
78
|
+
violations.add(createViolation(
|
|
79
|
+
filePath: filePath,
|
|
80
|
+
line: 1,
|
|
81
|
+
column: 1,
|
|
82
|
+
message:
|
|
83
|
+
'Project should have a test directory. Create a "test" directory with test files to ensure code quality and prevent regressions.',
|
|
84
|
+
));
|
|
85
|
+
} else if (totalTestFiles < minTestFiles) {
|
|
86
|
+
violations.add(createViolation(
|
|
87
|
+
filePath: filePath,
|
|
88
|
+
line: 1,
|
|
89
|
+
column: 1,
|
|
90
|
+
message:
|
|
91
|
+
'Project has a test directory but contains only $totalTestFiles test file(s). '
|
|
92
|
+
'Add more test files (minimum: $minTestFiles) to properly test your code.',
|
|
93
|
+
));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Find the project root by looking for pubspec.yaml
|
|
100
|
+
String? _findProjectRoot(String filePath) {
|
|
101
|
+
var dir = Directory(path.dirname(filePath));
|
|
102
|
+
|
|
103
|
+
while (dir.path != dir.parent.path) {
|
|
104
|
+
final pubspec = File(path.join(dir.path, 'pubspec.yaml'));
|
|
105
|
+
if (pubspec.existsSync()) {
|
|
106
|
+
return dir.path;
|
|
107
|
+
}
|
|
108
|
+
dir = dir.parent;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Find all test files in a directory (recursively)
|
|
115
|
+
List<File> _findTestFiles(Directory directory) {
|
|
116
|
+
final testFiles = <File>[];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
final entities = directory.listSync(recursive: true);
|
|
120
|
+
for (final entity in entities) {
|
|
121
|
+
if (entity is File) {
|
|
122
|
+
final fileName = path.basename(entity.path);
|
|
123
|
+
if (_testFilePattern.hasMatch(fileName)) {
|
|
124
|
+
testFiles.add(entity);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// If we can't read the directory, return empty list
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return testFiles;
|
|
133
|
+
}
|
|
134
|
+
}
|