-
Notifications
You must be signed in to change notification settings - Fork 24
Add feature_envy rule #323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
solid-illiaaihistov
wants to merge
2
commits into
solid-software:analysis_server_migration
Choose a base branch
from
solid-illiaaihistov:96-add-feature_envy
base: analysis_server_migration
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,735
−0
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import 'package:analyzer/analysis_rule/rule_context.dart'; | ||
| import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; | ||
| import 'package:analyzer/error/error.dart'; | ||
| import 'package:solid_lints/src/lints/feature_envy/models/feature_envy_parameters.dart'; | ||
| import 'package:solid_lints/src/lints/feature_envy/visitors/feature_envy_visitor.dart'; | ||
| import 'package:solid_lints/src/models/solid_lint_rule.dart'; | ||
|
|
||
| /// Warns if a method accesses fields or methods from a different class | ||
| /// more often than from its own class (feature envy). | ||
| /// | ||
| /// ### Example | ||
| /// BAD: | ||
| /// ```dart | ||
| /// class A { | ||
| /// int field; | ||
| /// A(this.field); | ||
| /// } | ||
| /// | ||
| /// class B { | ||
| /// int method(A a) => a.field * a.field; // LINT | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// GOOD: | ||
| /// ```dart | ||
| /// class A { | ||
| /// int field; | ||
| /// A(this.field); | ||
| /// | ||
| /// int method() => field * field; | ||
| /// } | ||
| /// | ||
| /// class B { | ||
| /// int method(A a) => a.method(); | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// ### Detection Algorithm | ||
| /// The rule detects feature envy using three metrics: | ||
| /// - **ATFD** (Access to Foreign Data): Accesses to members of a single | ||
| /// external class. Triggers if ATFD >= threshold (default 4). | ||
| /// - **LAA** (Locality of Attribute Access): The ratio of internal accesses | ||
| /// to total accesses. Triggers if LAA < threshold (default 0.33). | ||
| /// - **FDP** (Foreign Data Providers): The number of unique external classes | ||
| /// accessed. Triggers if FDP <= threshold (default 2). | ||
| /// | ||
| /// Accesses to other instances of the same class, non-project classes, | ||
| /// closures, nested functions, and data classes are ignored. | ||
| class FeatureEnvyRule extends SolidLintRule<FeatureEnvyParameters> { | ||
| /// Name of the lint. | ||
| static const lintName = 'feature_envy'; | ||
|
|
||
| static const _code = LintCode( | ||
| lintName, | ||
| "Avoid accessing members of `{0}` in `{1}` more often than own members.", | ||
| correctionMessage: | ||
| "Consider moving the related logic into the `{0}` class.", | ||
| ); | ||
|
|
||
| @override | ||
| DiagnosticCode get diagnosticCode => _code; | ||
|
|
||
| /// Creates a new instance of [FeatureEnvyRule]. | ||
| FeatureEnvyRule({ | ||
| required super.analysisOptionsLoader, | ||
| }) : super.withParameters( | ||
| name: lintName, | ||
| description: | ||
| 'Warns if a method accesses members of another class more ' | ||
| 'often than its own (feature envy).', | ||
| parametersParser: FeatureEnvyParameters.fromJson, | ||
| ); | ||
|
|
||
| @override | ||
| void registerNodeProcessors( | ||
| RuleVisitorRegistry registry, | ||
| RuleContext context, | ||
| ) { | ||
| super.registerNodeProcessors(registry, context); | ||
|
|
||
| final parameters = | ||
| getParametersForContext(context) ?? FeatureEnvyParameters.empty(); | ||
|
|
||
| registry.addMethodDeclaration( | ||
| this, | ||
| FeatureEnvyVisitor(this, parameters), | ||
| ); | ||
| } | ||
| } |
57 changes: 57 additions & 0 deletions
57
lib/src/lints/feature_envy/models/feature_envy_metrics.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import 'package:analyzer/dart/element/element.dart'; | ||
| import 'package:collection/collection.dart'; | ||
| import 'package:solid_lints/src/lints/feature_envy/models/feature_envy_parameters.dart'; | ||
|
|
||
| /// Calculated Feature Envy metrics for a method. | ||
| class FeatureEnvyMetrics { | ||
| /// Locality of Attribute Access (LAA) metric. | ||
| final double laa; | ||
|
|
||
| /// Foreign Data Providers (FDP) metric. | ||
| final int fdp; | ||
|
|
||
| /// Access to Foreign Data (ATFD) metric. | ||
| final int atfd; | ||
|
|
||
| /// The class element that is accessed the most externally. | ||
| final InterfaceElement? maxEnvyElement; | ||
|
|
||
| const FeatureEnvyMetrics._({ | ||
| required this.laa, | ||
| required this.fdp, | ||
| required this.atfd, | ||
| required this.maxEnvyElement, | ||
| }); | ||
|
|
||
| /// Checks if these metrics exceed the thresholds defined in [parameters], | ||
| /// indicating a feature envy code smell. | ||
| bool exceedsThresholds(FeatureEnvyParameters parameters) => | ||
| atfd >= parameters.atfdThreshold && | ||
| laa < parameters.laaThreshold && | ||
| fdp <= parameters.fdpThreshold; | ||
|
|
||
| /// Calculates metrics based on collected accesses. | ||
| factory FeatureEnvyMetrics.calculate({ | ||
| required int internalAccesses, | ||
| required Map<InterfaceElement, int> externalAccessCounts, | ||
| }) { | ||
| final totalExternalAccesses = externalAccessCounts.values.sum; | ||
| final totalAccesses = internalAccesses + totalExternalAccesses; | ||
|
|
||
| final laa = totalAccesses == 0 ? 1.0 : internalAccesses / totalAccesses; | ||
| final fdp = externalAccessCounts.length; | ||
|
|
||
| final maxEntry = externalAccessCounts.entries | ||
| .sorted((a, b) => b.value != a.value | ||
| ? b.value.compareTo(a.value) | ||
| : (a.key.name ?? '').compareTo(b.key.name ?? '')) | ||
| .firstOrNull; | ||
|
|
||
| return FeatureEnvyMetrics._( | ||
| laa: laa, | ||
| fdp: fdp, | ||
| atfd: maxEntry?.value ?? 0, | ||
| maxEnvyElement: maxEntry?.key, | ||
| ); | ||
| } | ||
| } | ||
54 changes: 54 additions & 0 deletions
54
lib/src/lints/feature_envy/models/feature_envy_parameters.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,54 @@ | ||||||||||||||||||||||||||||||
| import 'package:solid_lints/src/common/parameters/excluded_identifiers_list_parameter.dart'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Configuration parameters for the feature_envy rule. | ||||||||||||||||||||||||||||||
| class FeatureEnvyParameters { | ||||||||||||||||||||||||||||||
| /// A list of methods that should be excluded from the lint. | ||||||||||||||||||||||||||||||
| final ExcludedIdentifiersListParameter exclude; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Access to Foreign Data (ATFD) threshold. | ||||||||||||||||||||||||||||||
| /// Triggered if ATFD >= atfdThreshold. | ||||||||||||||||||||||||||||||
| final int atfdThreshold; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Locality of Attribute Access (LAA) threshold. | ||||||||||||||||||||||||||||||
| /// Triggered if LAA < laaThreshold. | ||||||||||||||||||||||||||||||
| final double laaThreshold; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Foreign Data Providers (FDP) threshold. | ||||||||||||||||||||||||||||||
| /// Triggered only if FDP <= fdpThreshold. | ||||||||||||||||||||||||||||||
| final int fdpThreshold; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Default Access to Foreign Data (ATFD) threshold. | ||||||||||||||||||||||||||||||
| static const _defaultAtfdThreshold = 4; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Default Locality of Attribute Access (LAA) threshold. | ||||||||||||||||||||||||||||||
| static const _defaultLaaThreshold = 0.33; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Default Foreign Data Providers (FDP) threshold. | ||||||||||||||||||||||||||||||
| static const _defaultFdpThreshold = 2; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Constructor for [FeatureEnvyParameters] model. | ||||||||||||||||||||||||||||||
| const FeatureEnvyParameters({ | ||||||||||||||||||||||||||||||
| required this.atfdThreshold, | ||||||||||||||||||||||||||||||
| required this.exclude, | ||||||||||||||||||||||||||||||
| required this.laaThreshold, | ||||||||||||||||||||||||||||||
| required this.fdpThreshold, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Empty [FeatureEnvyParameters] model with default values. | ||||||||||||||||||||||||||||||
| factory FeatureEnvyParameters.empty() => FeatureEnvyParameters( | ||||||||||||||||||||||||||||||
| atfdThreshold: _defaultAtfdThreshold, | ||||||||||||||||||||||||||||||
| exclude: ExcludedIdentifiersListParameter(exclude: []), | ||||||||||||||||||||||||||||||
| laaThreshold: _defaultLaaThreshold, | ||||||||||||||||||||||||||||||
| fdpThreshold: _defaultFdpThreshold, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Creates a [FeatureEnvyParameters] model from JSON data. | ||||||||||||||||||||||||||||||
| factory FeatureEnvyParameters.fromJson(Map<String, Object?> json) => | ||||||||||||||||||||||||||||||
| FeatureEnvyParameters( | ||||||||||||||||||||||||||||||
| exclude: ExcludedIdentifiersListParameter.defaultFromJson(json), | ||||||||||||||||||||||||||||||
| atfdThreshold: json['atfd_threshold'] as int? ?? _defaultAtfdThreshold, | ||||||||||||||||||||||||||||||
| laaThreshold: | ||||||||||||||||||||||||||||||
| (json['laa_threshold'] as num?)?.toDouble() ?? _defaultLaaThreshold, | ||||||||||||||||||||||||||||||
| fdpThreshold: json['fdp_threshold'] as int? ?? _defaultFdpThreshold, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
13 changes: 13 additions & 0 deletions
13
lib/src/lints/feature_envy/models/project_class_cache.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import 'package:analyzer/dart/element/element.dart'; | ||
| import 'package:solid_lints/src/utils/node_utils.dart'; | ||
|
|
||
| /// A cache for checking if an [InterfaceElement] belongs to the analyzed | ||
| /// project. | ||
| class ProjectClassCache { | ||
| final _cache = <InterfaceElement, bool>{}; | ||
|
|
||
| /// Returns `true` if [element] belongs to the analyzed project. | ||
| /// Results are cached for better performance during static analysis. | ||
| bool isProjectClass(InterfaceElement element) => | ||
| _cache.putIfAbsent(element, () => element.isFromProject); | ||
| } |
106 changes: 106 additions & 0 deletions
106
lib/src/lints/feature_envy/utils/member_access_utils.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import 'package:analyzer/dart/ast/ast.dart'; | ||
| import 'package:analyzer/dart/element/element.dart'; | ||
| import 'package:solid_lints/src/lints/feature_envy/models/project_class_cache.dart'; | ||
| import 'package:solid_lints/src/utils/node_utils.dart'; | ||
| import 'package:solid_lints/src/utils/types_utils.dart'; | ||
|
|
||
| /// Utility methods for analyzing member accesses within the feature envy lint. | ||
| abstract final class MemberAccessUtils { | ||
| /// Resolves the base element of a member access target expression. | ||
| static Element? resolveTargetElement( | ||
| Expression? target, { | ||
| required bool isPatternField, | ||
| }) { | ||
| if (isPatternField || target == null) return null; | ||
|
|
||
| final baseElement = switch (target.unwrapTarget) { | ||
| ExtensionOverride(:final argumentList) => | ||
| argumentList.arguments.firstOrNull?.staticType?.element, | ||
| final expr => expr?.staticType?.element, | ||
| }; | ||
|
|
||
| return switch (baseElement?.resolveTypeParameter) { | ||
| ExtensionElement(:final extendedType) => | ||
| extendedType.element?.resolveTypeParameter, | ||
| final resolved => resolved, | ||
| }; | ||
| } | ||
|
|
||
| /// Checks if the access to [c] is considered internal to [currentClass]. | ||
| static bool isInternalAccess( | ||
| Expression? target, | ||
| InterfaceElement c, { | ||
| required InterfaceElement currentClass, | ||
| bool isPatternField = false, | ||
| }) => | ||
| currentClass.isSameOrSubclassOf(c) && | ||
| (target != null || !isPatternField) && | ||
| switch (target?.unwrapTarget) { | ||
| ExtensionOverride(:final argumentList) => | ||
| argumentList.arguments.firstOrNull?.unwrapTarget.isThisOrSuper ?? | ||
| false, | ||
| final expr => expr.isThisOrSuperOrNull, | ||
| }; | ||
|
|
||
| /// Finds the highest level access expression that starts with [node]. | ||
| static Expression getAccessExpression(SimpleIdentifier node) => | ||
| findAccessExpression(node, node.parent); | ||
|
|
||
| /// Recursively finds the top-most expression of an access. | ||
| static Expression findAccessExpression(Expression child, AstNode? parent) => | ||
| switch (parent) { | ||
| ParenthesizedExpression(:final expression) when expression == child => | ||
| findAccessExpression(parent, parent.parent), | ||
| PropertyAccess(:final propertyName, :final target) | ||
| when propertyName == child && target.unwrapTarget.isThisOrSuper => | ||
| findAccessExpression(parent, parent.parent), | ||
| _ => child, | ||
| }; | ||
|
|
||
| /// Determines if [node] is the target of an access to an external member. | ||
| static bool isTargetOfExternalAccess( | ||
| SimpleIdentifier node, { | ||
| required InterfaceElement currentClass, | ||
| required ProjectClassCache projectClassCache, | ||
| }) { | ||
| final enclosing = node.element?.enclosingInterface; | ||
| if (enclosing == null || !currentClass.isSameOrSubclassOf(enclosing)) { | ||
| return false; | ||
| } | ||
|
|
||
| final expr = getAccessExpression(node); | ||
|
|
||
| bool isExternal(Element? e) => isExternalMember( | ||
| e, | ||
| currentClass: currentClass, | ||
| projectClassCache: projectClassCache, | ||
| ); | ||
|
|
||
| return switch (expr.parent) { | ||
| MethodInvocation(:final target, :final methodName) when target == expr => | ||
| isExternal(methodName.element), | ||
| PrefixedIdentifier(:final prefix, :final identifier) | ||
| when prefix == expr => | ||
| isExternal(identifier.element), | ||
| PropertyAccess(:final target, :final propertyName) when target == expr => | ||
| isExternal(propertyName.element), | ||
| CascadeExpression(:final target, :final cascadeSections) | ||
| when target == expr => | ||
| cascadeSections.any((s) => isExternal(s.memberElement)), | ||
| _ => false, | ||
| }; | ||
| } | ||
|
|
||
| /// Determines if [element] represents a member of an external class. | ||
| static bool isExternalMember( | ||
| Element? element, { | ||
| required InterfaceElement currentClass, | ||
| required ProjectClassCache projectClassCache, | ||
| }) => switch (element?.enclosingInterface) { | ||
| final enclosing? => | ||
| !currentClass.isSameOrSubclassOf(enclosing) && | ||
| !enclosing.isDataClass && | ||
| projectClassCache.isProjectClass(enclosing), | ||
| _ => false, | ||
| }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.