Add feature_envy rule#323
Conversation
…ing pattern matching and collection methods
There was a problem hiding this comment.
Code Review
This pull request introduces a new lint rule, feature_envy, which warns when a method accesses members of another class more often than its own. The implementation includes the core rule logic, metrics calculation (ATFD, LAA, FDP), AST visitors, and comprehensive unit tests. Feedback on the changes points out a critical compilation error in lib/src/utils/types_utils.dart where isOriginDeclaration is called on PropertyAccessorElement, which is not a valid property in package:analyzer. A suggestion to use !isSynthetic instead has been provided.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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, | ||
| ); |
There was a problem hiding this comment.
| 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, | |
| ); |
| 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, | ||
| ); |
There was a problem hiding this comment.
| 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; |
| final publicProperties = <String>{ | ||
| for (final el in elements) ...[ | ||
| ...el.getters | ||
| .where((g) => g.isPublicInstanceOrigin) | ||
| .map((g) => g.displayName) | ||
| .where((name) => !const {'hashCode', 'runtimeType'}.contains(name)), | ||
| ...el.setters | ||
| .where((s) => s.isPublicInstanceOrigin) | ||
| .map((s) => s.displayName), | ||
| ], | ||
| }; |
There was a problem hiding this comment.
| final publicProperties = <String>{ | |
| for (final el in elements) ...[ | |
| ...el.getters | |
| .where((g) => g.isPublicInstanceOrigin) | |
| .map((g) => g.displayName) | |
| .where((name) => !const {'hashCode', 'runtimeType'}.contains(name)), | |
| ...el.setters | |
| .where((s) => s.isPublicInstanceOrigin) | |
| .map((s) => s.displayName), | |
| ], | |
| }; | |
| final publicProperties = { | |
| for (final el in elements) ...[ | |
| ...el.getters.publicInstanceMemberNames.toSet().difference({ | |
| 'hashCode', | |
| 'runtimeType', | |
| }), | |
| ...el.setters.publicInstanceMemberNames, | |
| ], | |
| }; |
extension<T extends PropertyAccessorElement> on List<T> {
Iterable<String> get publicInstanceMemberNames =>
where((e) => e.isPublicInstanceOrigin).map((e) => e.displayName);
}| final publicMethods = <String>{ | ||
| for (final el in elements) | ||
| ...el.methods | ||
| .where((m) => m.isPublic && !m.isStatic) | ||
| .map((m) => m.name) | ||
| .nonNulls | ||
| .where( | ||
| (name) => !const { | ||
| 'toString', | ||
| '==', | ||
| 'copyWith', | ||
| 'toJson', | ||
| 'noSuchMethod', | ||
| }.contains(name), | ||
| ), | ||
| }; |
There was a problem hiding this comment.
| final publicMethods = <String>{ | |
| for (final el in elements) | |
| ...el.methods | |
| .where((m) => m.isPublic && !m.isStatic) | |
| .map((m) => m.name) | |
| .nonNulls | |
| .where( | |
| (name) => !const { | |
| 'toString', | |
| '==', | |
| 'copyWith', | |
| 'toJson', | |
| 'noSuchMethod', | |
| }.contains(name), | |
| ), | |
| }; | |
| final publicMethods = elements | |
| .expand((e) => e.methods) | |
| .where((m) => m.isPublic && !m.isStatic) | |
| .map((m) => m.name) | |
| .nonNulls | |
| .toSet() | |
| .difference({ | |
| 'toString', | |
| '==', | |
| 'copyWith', | |
| 'toJson', | |
| 'noSuchMethod', | |
| }); |
|
|
||
| /// Checks if a class is a Data Class based on Weight of Class. | ||
| bool get isDataClass { | ||
| const dataClassWeightThreshold = 0.33; |
There was a problem hiding this comment.
should this be configurable too?
| PropertyAccessorElement(:final isStatic) => !isStatic, | ||
| MethodElement(:final isStatic) => !isStatic, |
There was a problem hiding this comment.
| PropertyAccessorElement(:final isStatic) => !isStatic, | |
| MethodElement(:final isStatic) => !isStatic, | |
| PropertyAccessorElement(:final isStatic) || | |
| MethodElement(:final isStatic) || |
| InterfaceElement? get enclosingInterface { | ||
| Element? current = this; | ||
| while (current != null) { | ||
| if (current case InterfaceElement()) { | ||
| return current; | ||
| } | ||
| current = current.enclosingElement; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
| InterfaceElement? get enclosingInterface { | |
| Element? current = this; | |
| while (current != null) { | |
| if (current case InterfaceElement()) { | |
| return current; | |
| } | |
| current = current.enclosingElement; | |
| } | |
| return null; | |
| } | |
| InterfaceElement? get enclosingInterface => | |
| enclosingElements.whereType<InterfaceElement>().firstOrNull; | |
| Iterable<Element?> get enclosingElements sync* { | |
| for (Element? e = this; e != null; e = e.enclosingElement) { | |
| yield e; | |
| } | |
| } |
| AssignmentExpression(:final writeElement, :final readElement) => | ||
| writeElement ?? readElement, | ||
| PrefixExpression(:final writeElement, :final readElement) => | ||
| writeElement ?? readElement, | ||
| PostfixExpression(:final writeElement, :final readElement) => | ||
| writeElement ?? readElement, | ||
| IndexExpression(:final element) => element, |
There was a problem hiding this comment.
| AssignmentExpression(:final writeElement, :final readElement) => | |
| writeElement ?? readElement, | |
| PrefixExpression(:final writeElement, :final readElement) => | |
| writeElement ?? readElement, | |
| PostfixExpression(:final writeElement, :final readElement) => | |
| writeElement ?? readElement, | |
| IndexExpression(:final element) => element, | |
| AssignmentExpression(:final writeElement, :final readElement) || | |
| PostfixExpression(:final writeElement, :final readElement) || | |
| PrefixExpression( | |
| :final writeElement, | |
| :final readElement, | |
| ) => writeElement ?? readElement, | |
| IndexExpression(:final element) || |
There was a problem hiding this comment.
I think it can be a little less repetitive:
class IsDataClassTest extends PubPackageResolutionTest {
Future<void> test_isDataClass_for_pure_data_class() async => _test('''
class Rectangle {
final int width;
final int height;
const Rectangle(this.width, this.height);
}
''', isTrue);
Future<void> test_isDataClass_for_non_data_class() async => _test('''
class Rectangle {
final int width;
final int height;
const Rectangle(this.width, this.height);
int area() => width * height;
void printMe() {}
}
''', isFalse);
Future<void> test_isDataClass_collision_case() async => _test(
'''
class Base {
int get foo => 42;
}
class Sub extends Base {
@override
int foo() => 42;
}
''',
isFalse,
(d) => d.namePart.typeName.lexeme == 'Sub',
);
Future<void> _test(
String source,
dynamic matcher, [
bool Function(ClassDeclaration)? test,
]) async {
newFile(testFile.path, source);
final file = await resolveFile(testFile.path);
expect(
file.unit.declarations
.whereType<ClassDeclaration>()
.firstWhere(test ?? (_) => true)
.declaredFragment
?.element
.isDataClass,
matcher,
);
}
}| final metrics = FeatureEnvyMetrics.calculate( | ||
| internalAccesses: 1, | ||
| externalAccessCounts: {extA: 2}, | ||
| ); | ||
|
|
||
| expect(metrics.laa, equals(1 / 3)); | ||
| expect(metrics.fdp, equals(1)); | ||
| expect(metrics.atfd, equals(2)); | ||
| expect(metrics.maxEnvyElement, equals(extA)); | ||
| } |
There was a problem hiding this comment.
I think we can do something like this.
It's not shorter but it's less noisy and the tests themselves become less repetitive
_expectMetrics(
internalAccesses: 1,
externalAccessCounts: {extA: 2},
laa: 1 / 3,
fdp: 1,
atfd: 2,
maxEnvyElement: extA,
);
}
void _expectMetrics({
required int internalAccesses,
required Map<InterfaceElement, int> externalAccessCounts,
required int atfd,
required int fdp,
required double laa,
required ClassElement? maxEnvyElement,
}) {
final metrics = FeatureEnvyMetrics.calculate(
internalAccesses: internalAccesses,
externalAccessCounts: externalAccessCounts,
);
expect(metrics.laa, equals(laa));
expect(metrics.fdp, equals(fdp));
expect(metrics.atfd, equals(atfd));
expect(metrics.maxEnvyElement, equals(maxEnvyElement));
}
Closes #96