Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ solid_lints:
max_complexity: 10

double_literal_format: true
feature_envy:
atfd_threshold: 4
laa_threshold: 0.33
fdp_threshold: 2

function_lines_of_code:
max_lines: 200
Expand Down
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:solid_lints/src/lints/avoid_unused_parameters/avoid_unused_param
import 'package:solid_lints/src/lints/avoid_using_api/avoid_using_api_rule.dart';
import 'package:solid_lints/src/lints/cyclomatic_complexity/cyclomatic_complexity_rule.dart';
import 'package:solid_lints/src/lints/double_literal_format/double_literal_format_rule.dart';
import 'package:solid_lints/src/lints/feature_envy/feature_envy_rule.dart';
import 'package:solid_lints/src/lints/function_lines_of_code/function_lines_of_code_rule.dart';
import 'package:solid_lints/src/lints/member_ordering/member_ordering_rule.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/named_parameters_ordering_rule.dart';
Expand Down Expand Up @@ -65,6 +66,7 @@ class SolidLintsPlugin extends Plugin {
AvoidUsingApiRule(analysisOptionsLoader: analysisLoader),
CyclomaticComplexityRule(analysisOptionsLoader: analysisLoader),
DoubleLiteralFormatRule(),
FeatureEnvyRule(analysisOptionsLoader: analysisLoader),
FunctionLinesOfCodeRule(analysisOptionsLoader: analysisLoader),
MemberOrderingRule(analysisOptionsLoader: analysisLoader),
NamedParametersOrderingRule(analysisOptionsLoader: analysisLoader),
Expand Down
89 changes: 89 additions & 0 deletions lib/src/lints/feature_envy/feature_envy_rule.dart
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 lib/src/lints/feature_envy/models/feature_envy_metrics.dart
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,
);
Comment on lines +38 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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,
);
final totalAccesses = internalAccesses + externalAccessCounts.values.sum;
final maxEntry = externalAccessCounts.entries.multiSortedBy([
(e) => e.value,
(e) => e.key.name ?? '',
]).firstOrNull;
return FeatureEnvyMetrics._(
laa: totalAccesses == 0 ? 1.0 : internalAccesses / totalAccesses,
fdp: externalAccessCounts.length,
atfd: maxEntry?.value ?? 0,
maxEnvyElement: maxEntry?.key,
);

}
}
54 changes: 54 additions & 0 deletions lib/src/lints/feature_envy/models/feature_envy_parameters.dart
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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,
);
FeatureEnvyParameters.fromJson(Map<String, Object?> json)
: 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;

}
13 changes: 13 additions & 0 deletions lib/src/lints/feature_envy/models/project_class_cache.dart
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 lib/src/lints/feature_envy/utils/member_access_utils.dart
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,
};
}
Loading
Loading