merge/audit-and-state-map #7
10 changed files with 955 additions and 749 deletions
refactor(rules): provider rules judge facts; runChecks takes only the model
Second step of the state-map seam. The five ChangeNotifier/provider rules walked resolved units from runProviderRules. Their recognisers move to the Dart front end (dart_provider_facts.dart) and emit a NotifierModel of neutral facts (facts.dart): - NotifierFact / NotifierMethod / FieldMutation: an observable object that must announce its own changes, its methods, what each calls, mutates, and whether it notifies directly. Deliberately general enough for a Qt QObject or a hand-rolled observer subject. - consumer sites, provider declarations, member uses per file. - NonSubscribingRead: every read made while building without subscribing; whether the type is observable is now the rule's filter, not the recogniser's. - ProvidersCreatedInBuild: providers constructed in a build method. The transitive notify closure, the observable/lifecycle/wrapper judgements and all messages stay in provider_rules.dart, which no longer imports the analyzer. runChecks now takes only a StateFlow: no Project, no FileOutcome. The scanned directories ride on the model (StateFlow.directories) for the dead-handler scope claim. facts.dart is exported publicly. No behaviour change, checked byte for byte: `check --format json` is identical on the fixture and on resolved mirrors of nuwayze, tick, dex and word_alive. 128 tests pass.
commit
c3f1fb6319
|
|
@ -24,7 +24,8 @@ export 'src/index.dart'
|
|||
export 'src/model.dart'
|
||||
show DeclarationInfo, FileModel, ProjectModel, buildProjectModel;
|
||||
export 'src/pipeline.dart' show ProjectAnalysis, analyzeProject;
|
||||
export 'src/provider_rules.dart' show ProviderDeclaration, providerRuleNote;
|
||||
export 'src/facts.dart';
|
||||
export 'src/provider_rules.dart' show providerRuleNote;
|
||||
export 'src/recall.dart'
|
||||
show RecallCase, RecallOutcome, RecallReport, loadRecallCases, runRecall;
|
||||
export 'src/project.dart' show Project, loadProject;
|
||||
|
|
|
|||
535
lib/src/dart_provider_facts.dart
Normal file
535
lib/src/dart_provider_facts.dart
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
/// The Dart front end's recognisers for the `ChangeNotifier` / `provider`
|
||||
/// rules. They emit the neutral facts in facts.dart; provider_rules.dart judges
|
||||
/// them and never sees a syntax tree.
|
||||
library;
|
||||
|
||||
import 'package:analyzer/dart/analysis/results.dart';
|
||||
import 'package:analyzer/dart/ast/ast.dart';
|
||||
import 'package:analyzer/dart/ast/visitor.dart';
|
||||
import 'package:analyzer/source/line_info.dart';
|
||||
|
||||
import 'facts.dart';
|
||||
import 'project.dart';
|
||||
|
||||
/// Recognises `ChangeNotifier` classes (by supertype or mixin name).
|
||||
class _NotifierCollector extends RecursiveAstVisitor<void> {
|
||||
_NotifierCollector(this.relativePath, this.lineInfo, this.notifiers);
|
||||
|
||||
final String relativePath;
|
||||
final LineInfo lineInfo;
|
||||
final List<NotifierFact> notifiers;
|
||||
|
||||
static final RegExp _notify = RegExp(
|
||||
r'notifyListeners',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static bool _isChangeNotifier(ClassDeclaration node) {
|
||||
final superclass = node.extendsClause?.superclass.toSource() ?? '';
|
||||
if (superclass.contains('ChangeNotifier')) return true;
|
||||
return (node.withClause?.mixinTypes ?? const <NamedType>[]).any(
|
||||
(type) => type.toSource().contains('ChangeNotifier'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitClassDeclaration(ClassDeclaration node) {
|
||||
if (!_isChangeNotifier(node)) {
|
||||
super.visitClassDeclaration(node);
|
||||
return;
|
||||
}
|
||||
|
||||
final fields = <String>{};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! FieldDeclaration) continue;
|
||||
for (final variable in member.fields.variables) {
|
||||
fields.add(variable.name.lexeme);
|
||||
}
|
||||
}
|
||||
|
||||
final methods = <String, NotifierMethod>{};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! MethodDeclaration) continue;
|
||||
if (member.isGetter) continue;
|
||||
if (member.isStatic) continue;
|
||||
|
||||
final source = member.toSource();
|
||||
final called = _CalledNames();
|
||||
member.body.accept(called);
|
||||
final mutations = <FieldMutation>[];
|
||||
for (final field in fields) {
|
||||
// `_x = ...` or `this._x = ...`, but not `==`.
|
||||
final pattern = RegExp(
|
||||
r'(^|[^.\w])(this\.)?' + RegExp.escape(field) + r'\s*=(?!=)',
|
||||
);
|
||||
if (pattern.hasMatch(source)) {
|
||||
mutations.add(
|
||||
FieldMutation(
|
||||
field,
|
||||
lineInfo.getLocation(member.offset).lineNumber,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
methods[member.name.lexeme] = NotifierMethod(
|
||||
name: member.name.lexeme,
|
||||
calledNames: called.names,
|
||||
directNotify: _notify.hasMatch(source),
|
||||
mutations: mutations,
|
||||
);
|
||||
}
|
||||
|
||||
// What the UI can observe.
|
||||
final observable = <String>{
|
||||
for (final field in fields)
|
||||
if (!field.startsWith('_')) field,
|
||||
};
|
||||
final apiMembers = <String>{
|
||||
for (final field in fields)
|
||||
if (!field.startsWith('_')) field,
|
||||
};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! MethodDeclaration) continue;
|
||||
if (!member.isGetter) continue;
|
||||
if (member.name.lexeme.startsWith('_')) continue;
|
||||
apiMembers.add(member.name.lexeme);
|
||||
final source = member.toSource();
|
||||
for (final field in fields) {
|
||||
if (RegExp(r'\b' + RegExp.escape(field) + r'\b').hasMatch(source)) {
|
||||
observable.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (methods.isNotEmpty) {
|
||||
notifiers.add(
|
||||
NotifierFact(
|
||||
name: node.namePart.typeName.lexeme,
|
||||
file: relativePath,
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
methods: methods.values.toList(),
|
||||
observableFields: observable,
|
||||
apiMembers: apiMembers,
|
||||
),
|
||||
);
|
||||
}
|
||||
super.visitClassDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Method names invoked on `this` (or unqualified) within a body.
|
||||
class _CalledNames extends RecursiveAstVisitor<void> {
|
||||
final Set<String> names = {};
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final target = node.target;
|
||||
if (target == null || target is ThisExpression) {
|
||||
names.add(node.methodName.name);
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
|
||||
final function = node.function;
|
||||
if (function is SimpleIdentifier) names.add(function.name);
|
||||
super.visitFunctionExpressionInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumer sites that read a notifier, used to say whether a Consumer can ever
|
||||
/// rebuild.
|
||||
class _ConsumerCollector extends RecursiveAstVisitor<void> {
|
||||
_ConsumerCollector(this.relativePath, this.lineInfo, this.consumedBy);
|
||||
|
||||
final String relativePath;
|
||||
final LineInfo lineInfo;
|
||||
final Map<String, List<String>> consumedBy;
|
||||
|
||||
static const Set<String> _widgets = {
|
||||
'Consumer',
|
||||
'Selector',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableBuilder',
|
||||
'AnimatedBuilder',
|
||||
};
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
final name = type.name.lexeme;
|
||||
if (!_widgets.contains(name)) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
for (final argument
|
||||
in type.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
|
||||
final typeName = argument.toSource().split('<').first.trim();
|
||||
consumedBy
|
||||
.putIfAbsent(typeName, () => [])
|
||||
.add('$relativePath:${lineInfo.getLocation(node.offset).lineNumber}');
|
||||
}
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final name = node.methodName.name;
|
||||
if (name == 'watch' || name == 'read' || name == 'of') {
|
||||
for (final argument
|
||||
in node.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
|
||||
final typeName = argument.toSource().split('<').first.trim();
|
||||
consumedBy
|
||||
.putIfAbsent(typeName, () => [])
|
||||
.add(
|
||||
'$relativePath:${lineInfo.getLocation(node.offset).lineNumber}',
|
||||
);
|
||||
}
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects `provider` package provider declarations (all provider types).
|
||||
class _ProviderDeclarationCollector extends RecursiveAstVisitor<void> {
|
||||
_ProviderDeclarationCollector(
|
||||
this.relativePath,
|
||||
this.lineInfo,
|
||||
this.declarations,
|
||||
);
|
||||
|
||||
final String relativePath;
|
||||
final LineInfo lineInfo;
|
||||
final List<ProviderDeclaration> declarations;
|
||||
|
||||
static const Set<String> kinds = {
|
||||
'Provider',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
'FutureProvider',
|
||||
'StreamProvider',
|
||||
'StateProvider',
|
||||
'ProxyProvider',
|
||||
'ChangeNotifierProxyProvider',
|
||||
'InheritedProvider',
|
||||
};
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
var kind = type.name.lexeme;
|
||||
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
|
||||
if (!kinds.contains(kind)) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// The provider package takes `create:`/`update:`; Riverpod passes a
|
||||
// positional `(ref)` callback. That plus a `package:provider` import keeps
|
||||
// the two apart.
|
||||
final named = <String>{};
|
||||
for (final argument in node.argumentList.arguments) {
|
||||
if (argument is NamedArgument) named.add(argument.name.lexeme);
|
||||
}
|
||||
final looksLikeRiverpod =
|
||||
!named.contains('create') &&
|
||||
!named.contains('update') &&
|
||||
!named.contains('value') &&
|
||||
node.constructorName.name == null;
|
||||
if (looksLikeRiverpod) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
|
||||
final arguments = type.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
declarations.add(
|
||||
ProviderDeclaration(
|
||||
kind: kind,
|
||||
valueType: arguments.isEmpty
|
||||
? null
|
||||
: arguments.first.toSource().split('<').first.trim(),
|
||||
file: relativePath,
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
),
|
||||
);
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds `context.read<T>()` / `Provider.of<T>(context, listen: false)` used
|
||||
/// inside a `build` method for a type that is a `Listenable`.
|
||||
///
|
||||
/// `read` never subscribes, so a widget that displays such a value will not
|
||||
/// rebuild when it changes. This targets ChangeNotifier-provided state, which is
|
||||
/// why it only fires for listenable provider types or known ChangeNotifiers.
|
||||
class _ReadInBuildCollector extends RecursiveAstVisitor<void> {
|
||||
_ReadInBuildCollector(this.relativePath, this.lineInfo, this.reads);
|
||||
|
||||
final String relativePath;
|
||||
final LineInfo lineInfo;
|
||||
final List<NonSubscribingRead> reads;
|
||||
|
||||
bool _isBuildMethod(MethodDeclaration node) {
|
||||
if (node.name.lexeme != 'build') return false;
|
||||
final returnType = node.returnType?.toSource() ?? '';
|
||||
if (returnType.contains('Widget')) return true;
|
||||
final parameters = node.parameters;
|
||||
if (parameters == null) return false;
|
||||
for (final parameter in parameters.parameters) {
|
||||
if (parameter.toSource().contains('BuildContext')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
void visitMethodDeclaration(MethodDeclaration node) {
|
||||
if (!_isBuildMethod(node)) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
final collector = _ReadCallCollector();
|
||||
node.body.accept(collector);
|
||||
for (final call in collector.calls) {
|
||||
reads.add(
|
||||
NonSubscribingRead(
|
||||
type: call.type,
|
||||
call: call.source,
|
||||
path: relativePath,
|
||||
line: lineInfo.getLocation(call.offset).lineNumber,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Still descend: a nested widget may have its own build method.
|
||||
super.visitMethodDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadCall {
|
||||
_ReadCall(this.type, this.offset, this.source);
|
||||
|
||||
final String type;
|
||||
final int offset;
|
||||
final String source;
|
||||
}
|
||||
|
||||
/// Collects non-subscribing reads made directly in a build body.
|
||||
///
|
||||
/// It deliberately does NOT descend into nested functions: a read inside a
|
||||
/// closure (a button callback, a BlocListener listener, a Selector builder) is
|
||||
/// not made while the build method runs, so not subscribing there is usually
|
||||
/// exactly right. Hand-checking the first real findings showed every one of them
|
||||
/// was a closure read, which is why this is a precision guard rather than an
|
||||
/// oversight - it costs recall on reads inside a `Builder(builder: ...)`.
|
||||
class _ReadCallCollector extends RecursiveAstVisitor<void> {
|
||||
final List<_ReadCall> calls = [];
|
||||
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final arguments = node.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
if (arguments.isNotEmpty) {
|
||||
final type = arguments.first.toSource().split('<').first.trim();
|
||||
final name = node.methodName.name;
|
||||
final target = node.target?.toSource();
|
||||
|
||||
// context.read<T>()
|
||||
if (name == 'read' && (target == 'context' || target == 'this.context')) {
|
||||
calls.add(_ReadCall(type, node.offset, node.toSource()));
|
||||
}
|
||||
|
||||
// Provider.of<T>(context, listen: false)
|
||||
if (name == 'of' && (target == 'Provider' || target == 'provider')) {
|
||||
final listens = node.argumentList.arguments.any(
|
||||
(argument) =>
|
||||
argument is NamedArgument &&
|
||||
argument.name.lexeme == 'listen' &&
|
||||
argument.argumentExpression.toSource().trim() == 'false',
|
||||
);
|
||||
if (listens) calls.add(_ReadCall(type, node.offset, node.toSource()));
|
||||
}
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects member reads (`x.someGetter`, `x.someField`) per file, so a public
|
||||
/// notifier getter that nothing reads can be identified as dead state.
|
||||
class _MemberUseCollector extends RecursiveAstVisitor<void> {
|
||||
_MemberUseCollector(this.uses);
|
||||
|
||||
final Set<String> uses;
|
||||
|
||||
@override
|
||||
void visitPropertyAccess(PropertyAccess node) {
|
||||
uses.add(node.propertyName.name);
|
||||
super.visitPropertyAccess(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
||||
uses.add(node.identifier.name);
|
||||
super.visitPrefixedIdentifier(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a provider constructed directly inside a build method.
|
||||
///
|
||||
/// Without `.value` the provider's `create` callback runs on every rebuild, so a
|
||||
/// new notifier (and a new listener registration) is created each time and any
|
||||
/// state it held is lost.
|
||||
class _ProviderInBuildCollector extends RecursiveAstVisitor<void> {
|
||||
_ProviderInBuildCollector(this.relativePath, this.lineInfo, this.facts);
|
||||
|
||||
final String relativePath;
|
||||
final LineInfo lineInfo;
|
||||
final List<ProvidersCreatedInBuild> facts;
|
||||
final List<String> _classStack = [];
|
||||
|
||||
static const Set<String> kinds = {
|
||||
'Provider',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
'ProxyProvider',
|
||||
'ChangeNotifierProxyProvider',
|
||||
};
|
||||
|
||||
bool _isBuildMethod(MethodDeclaration node) {
|
||||
if (node.name.lexeme != 'build') return false;
|
||||
if ((node.returnType?.toSource() ?? '').contains('Widget')) return true;
|
||||
final parameters = node.parameters;
|
||||
if (parameters == null) return false;
|
||||
return parameters.parameters.any(
|
||||
(parameter) => parameter.toSource().contains('BuildContext'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitClassDeclaration(ClassDeclaration node) {
|
||||
_classStack.add(node.namePart.typeName.lexeme);
|
||||
super.visitClassDeclaration(node);
|
||||
_classStack.removeLast();
|
||||
}
|
||||
|
||||
@override
|
||||
void visitMethodDeclaration(MethodDeclaration node) {
|
||||
if (!_isBuildMethod(node)) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
final collector = _ProviderCreationCollector();
|
||||
node.body.accept(collector);
|
||||
if (collector.creations.isEmpty) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
|
||||
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
|
||||
facts.add(
|
||||
ProvidersCreatedInBuild(
|
||||
owner: owner,
|
||||
path: relativePath,
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
creations: [
|
||||
for (final creation in collector.creations)
|
||||
ProviderCreation(
|
||||
kind: creation.kind,
|
||||
valueType: creation.valueType,
|
||||
line: lineInfo.getLocation(creation.offset).lineNumber,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
super.visitMethodDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
class _Creation {
|
||||
_Creation(this.kind, this.valueType, this.offset, this.source);
|
||||
|
||||
final String kind;
|
||||
final String valueType;
|
||||
final int offset;
|
||||
final String source;
|
||||
}
|
||||
|
||||
class _ProviderCreationCollector extends RecursiveAstVisitor<void> {
|
||||
final List<_Creation> creations = [];
|
||||
|
||||
// A closure runs later, so a provider created there is a different problem.
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
var kind = type.name.lexeme;
|
||||
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
|
||||
// `.value` passes an existing instance, which is correct.
|
||||
if (node.constructorName.name?.name == 'value') {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
if (_ProviderInBuildCollector.kinds.contains(kind)) {
|
||||
final arguments =
|
||||
type.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
creations.add(
|
||||
_Creation(
|
||||
kind,
|
||||
arguments.isEmpty
|
||||
? '?'
|
||||
: arguments.first.toSource().split('<').first.trim(),
|
||||
node.offset,
|
||||
node.toSource(),
|
||||
),
|
||||
);
|
||||
}
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the notifier/provider facts from resolved units, in unit order.
|
||||
NotifierModel collectNotifierModel(
|
||||
Project project,
|
||||
Map<String, ResolvedUnitResult> unitsByPath,
|
||||
) {
|
||||
final notifiers = <NotifierFact>[];
|
||||
final consumedBy = <String, List<String>>{};
|
||||
final declarations = <ProviderDeclaration>[];
|
||||
final memberUses = <String, Set<String>>{};
|
||||
final reads = <NonSubscribingRead>[];
|
||||
final createdInBuild = <ProvidersCreatedInBuild>[];
|
||||
for (final entry in unitsByPath.entries) {
|
||||
final unit = entry.value.unit;
|
||||
final lineInfo = entry.value.lineInfo;
|
||||
final relative = project.relative(entry.key);
|
||||
unit.accept(_NotifierCollector(relative, lineInfo, notifiers));
|
||||
unit.accept(_ConsumerCollector(relative, lineInfo, consumedBy));
|
||||
unit.accept(
|
||||
_ProviderDeclarationCollector(relative, lineInfo, declarations),
|
||||
);
|
||||
final uses = <String>{};
|
||||
unit.accept(_MemberUseCollector(uses));
|
||||
memberUses[relative] = uses;
|
||||
unit.accept(_ReadInBuildCollector(relative, lineInfo, reads));
|
||||
unit.accept(_ProviderInBuildCollector(relative, lineInfo, createdInBuild));
|
||||
}
|
||||
return NotifierModel(
|
||||
notifiers: notifiers,
|
||||
consumedBy: consumedBy,
|
||||
declarations: declarations,
|
||||
memberUses: memberUses,
|
||||
nonSubscribingReads: reads,
|
||||
createdInBuild: createdInBuild,
|
||||
);
|
||||
}
|
||||
|
|
@ -151,3 +151,298 @@ class UnresolvedSummary {
|
|||
'names': names.toList()..sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/// An observable object that must announce its own changes: a Dart
|
||||
/// `ChangeNotifier`, a Qt `QObject` with signals, a hand-rolled observer
|
||||
/// subject. Whether a mutation is ever announced is the rules' judgement.
|
||||
class NotifierFact {
|
||||
NotifierFact({
|
||||
required this.name,
|
||||
required this.file,
|
||||
required this.line,
|
||||
required this.methods,
|
||||
required this.observableFields,
|
||||
required this.apiMembers,
|
||||
});
|
||||
|
||||
factory NotifierFact.fromJson(Map<String, Object?> json) => NotifierFact(
|
||||
name: json['name']! as String,
|
||||
file: json['file']! as String,
|
||||
line: json['line']! as int,
|
||||
methods: [
|
||||
for (final method in json['methods']! as List)
|
||||
NotifierMethod.fromJson(method as Map<String, Object?>),
|
||||
],
|
||||
observableFields: {...(json['observableFields']! as List).cast<String>()},
|
||||
apiMembers: {...(json['apiMembers']! as List).cast<String>()},
|
||||
);
|
||||
|
||||
final String name;
|
||||
final String file;
|
||||
final int line;
|
||||
|
||||
/// Instance methods, in declaration order.
|
||||
final List<NotifierMethod> methods;
|
||||
|
||||
/// State a consumer can observe (public fields, and private fields a public
|
||||
/// getter exposes).
|
||||
final Set<String> observableFields;
|
||||
|
||||
/// The public read surface: public getters and fields.
|
||||
final Set<String> apiMembers;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
'file': file,
|
||||
'line': line,
|
||||
'methods': [for (final method in methods) method.toJson()],
|
||||
'observableFields': observableFields.toList()..sort(),
|
||||
'apiMembers': apiMembers.toList()..sort(),
|
||||
};
|
||||
}
|
||||
|
||||
class NotifierMethod {
|
||||
NotifierMethod({
|
||||
required this.name,
|
||||
required this.calledNames,
|
||||
required this.directNotify,
|
||||
required this.mutations,
|
||||
});
|
||||
|
||||
factory NotifierMethod.fromJson(Map<String, Object?> json) => NotifierMethod(
|
||||
name: json['name']! as String,
|
||||
calledNames: {...(json['calledNames']! as List).cast<String>()},
|
||||
directNotify: json['directNotify']! as bool,
|
||||
mutations: [
|
||||
for (final mutation in json['mutations']! as List)
|
||||
FieldMutation.fromJson(mutation as Map<String, Object?>),
|
||||
],
|
||||
);
|
||||
|
||||
final String name;
|
||||
|
||||
/// Methods it calls on its own object.
|
||||
final Set<String> calledNames;
|
||||
|
||||
/// It announces a change itself (`notifyListeners()`, `emit signal`).
|
||||
final bool directNotify;
|
||||
|
||||
/// Fields it assigns, in the order they were found.
|
||||
final List<FieldMutation> mutations;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
'calledNames': calledNames.toList()..sort(),
|
||||
'directNotify': directNotify,
|
||||
'mutations': [for (final mutation in mutations) mutation.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
class FieldMutation {
|
||||
FieldMutation(this.name, this.line);
|
||||
|
||||
factory FieldMutation.fromJson(Map<String, Object?> json) =>
|
||||
FieldMutation(json['name']! as String, json['line']! as int);
|
||||
|
||||
final String name;
|
||||
final int line;
|
||||
|
||||
Map<String, Object?> toJson() => {'name': name, 'line': line};
|
||||
}
|
||||
|
||||
/// A read of a provided value made directly while building UI, without
|
||||
/// subscribing to it (`context.read<T>()`). Whether T is observable is the
|
||||
/// rule's judgement.
|
||||
class NonSubscribingRead {
|
||||
NonSubscribingRead({
|
||||
required this.type,
|
||||
required this.call,
|
||||
required this.path,
|
||||
required this.line,
|
||||
});
|
||||
|
||||
factory NonSubscribingRead.fromJson(Map<String, Object?> json) =>
|
||||
NonSubscribingRead(
|
||||
type: json['type']! as String,
|
||||
call: json['call']! as String,
|
||||
path: json['path']! as String,
|
||||
line: json['line']! as int,
|
||||
);
|
||||
|
||||
final String type;
|
||||
final String call;
|
||||
final String path;
|
||||
final int line;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'type': type,
|
||||
'call': call,
|
||||
'path': path,
|
||||
'line': line,
|
||||
};
|
||||
}
|
||||
|
||||
/// A build method that constructs providers without reusing an instance, so
|
||||
/// their create callbacks rerun on every rebuild.
|
||||
class ProvidersCreatedInBuild {
|
||||
ProvidersCreatedInBuild({
|
||||
required this.owner,
|
||||
required this.path,
|
||||
required this.line,
|
||||
required this.creations,
|
||||
});
|
||||
|
||||
factory ProvidersCreatedInBuild.fromJson(Map<String, Object?> json) =>
|
||||
ProvidersCreatedInBuild(
|
||||
owner: json['owner']! as String,
|
||||
path: json['path']! as String,
|
||||
line: json['line']! as int,
|
||||
creations: [
|
||||
for (final creation in json['creations']! as List)
|
||||
ProviderCreation.fromJson(creation as Map<String, Object?>),
|
||||
],
|
||||
);
|
||||
|
||||
final String owner;
|
||||
final String path;
|
||||
final int line;
|
||||
|
||||
/// In source order.
|
||||
final List<ProviderCreation> creations;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'owner': owner,
|
||||
'path': path,
|
||||
'line': line,
|
||||
'creations': [for (final creation in creations) creation.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
class ProviderCreation {
|
||||
ProviderCreation({
|
||||
required this.kind,
|
||||
required this.valueType,
|
||||
required this.line,
|
||||
});
|
||||
|
||||
factory ProviderCreation.fromJson(Map<String, Object?> json) =>
|
||||
ProviderCreation(
|
||||
kind: json['kind']! as String,
|
||||
valueType: json['valueType']! as String,
|
||||
line: json['line']! as int,
|
||||
);
|
||||
|
||||
final String kind;
|
||||
final String valueType;
|
||||
final int line;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kind': kind,
|
||||
'valueType': valueType,
|
||||
'line': line,
|
||||
};
|
||||
}
|
||||
|
||||
/// Everything the notifier/provider rules judge.
|
||||
class NotifierModel {
|
||||
NotifierModel({
|
||||
this.notifiers = const [],
|
||||
this.consumedBy = const {},
|
||||
this.declarations = const [],
|
||||
this.memberUses = const {},
|
||||
this.nonSubscribingReads = const [],
|
||||
this.createdInBuild = const [],
|
||||
});
|
||||
|
||||
factory NotifierModel.fromJson(Map<String, Object?> json) => NotifierModel(
|
||||
notifiers: [
|
||||
for (final notifier in json['notifiers']! as List)
|
||||
NotifierFact.fromJson(notifier as Map<String, Object?>),
|
||||
],
|
||||
consumedBy: {
|
||||
for (final entry in (json['consumedBy']! as Map).entries)
|
||||
entry.key as String: [...(entry.value as List).cast<String>()],
|
||||
},
|
||||
declarations: [
|
||||
for (final declaration in json['declarations']! as List)
|
||||
ProviderDeclaration.fromJson(declaration as Map<String, Object?>),
|
||||
],
|
||||
memberUses: {
|
||||
for (final entry in (json['memberUses']! as Map).entries)
|
||||
entry.key as String: {...(entry.value as List).cast<String>()},
|
||||
},
|
||||
nonSubscribingReads: [
|
||||
for (final read in json['nonSubscribingReads']! as List)
|
||||
NonSubscribingRead.fromJson(read as Map<String, Object?>),
|
||||
],
|
||||
createdInBuild: [
|
||||
for (final build in json['createdInBuild']! as List)
|
||||
ProvidersCreatedInBuild.fromJson(build as Map<String, Object?>),
|
||||
],
|
||||
);
|
||||
|
||||
final List<NotifierFact> notifiers;
|
||||
|
||||
/// Observed type -> consumer sites (`path:line`), in source order.
|
||||
final Map<String, List<String>> consumedBy;
|
||||
final List<ProviderDeclaration> declarations;
|
||||
|
||||
/// Project-relative file -> member names read in it.
|
||||
final Map<String, Set<String>> memberUses;
|
||||
final List<NonSubscribingRead> nonSubscribingReads;
|
||||
final List<ProvidersCreatedInBuild> createdInBuild;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'notifiers': [for (final notifier in notifiers) notifier.toJson()],
|
||||
'consumedBy': consumedBy,
|
||||
'declarations': [
|
||||
for (final declaration in declarations) declaration.toJson(),
|
||||
],
|
||||
'memberUses': {
|
||||
for (final entry in memberUses.entries)
|
||||
entry.key: entry.value.toList()..sort(),
|
||||
},
|
||||
'nonSubscribingReads': [
|
||||
for (final read in nonSubscribingReads) read.toJson(),
|
||||
],
|
||||
'createdInBuild': [for (final build in createdInBuild) build.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
/// A provider declaration (`ChangeNotifierProvider<T>(create: ...)`).
|
||||
class ProviderDeclaration {
|
||||
ProviderDeclaration({
|
||||
required this.kind,
|
||||
required this.valueType,
|
||||
required this.file,
|
||||
required this.line,
|
||||
});
|
||||
|
||||
factory ProviderDeclaration.fromJson(Map<String, Object?> json) =>
|
||||
ProviderDeclaration(
|
||||
kind: json['kind']! as String,
|
||||
valueType: json['valueType'] as String?,
|
||||
file: json['file']! as String,
|
||||
line: json['line']! as int,
|
||||
);
|
||||
|
||||
/// `Provider`, `ChangeNotifierProvider`, `ProxyProvider`, ...
|
||||
final String kind;
|
||||
final String? valueType;
|
||||
final String file;
|
||||
final int line;
|
||||
|
||||
/// Providers whose value is a `Listenable`, so consumers must subscribe.
|
||||
bool get isListenable => const {
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
}.contains(kind);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kind': kind,
|
||||
if (valueType != null) 'valueType': valueType,
|
||||
'file': file,
|
||||
'line': line,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,6 @@ Future<ProjectAnalysis> analyzeProject(
|
|||
);
|
||||
}
|
||||
final report = runChecks(
|
||||
project: project,
|
||||
outcomes: run.outcomes,
|
||||
flow: flow,
|
||||
enabledRules: enabledRules,
|
||||
baseline: baseline,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
import 'package:analyzer/dart/ast/ast.dart';
|
||||
import 'package:analyzer/dart/ast/visitor.dart';
|
||||
import 'package:analyzer/source/line_info.dart';
|
||||
|
||||
import 'analysis.dart';
|
||||
import 'project.dart';
|
||||
import 'rules.dart';
|
||||
|
||||
/// `ChangeNotifier` / `provider` rules.
|
||||
///
|
||||
/// These target the `provider` half of the state-management surface, where the
|
||||
|
|
@ -14,12 +6,13 @@ import 'rules.dart';
|
|||
/// code often routes through a private wrapper (`_notifyListeners()`), so
|
||||
/// "did it notify?" has to be answered transitively over the class's own call
|
||||
/// graph rather than by looking for the literal call.
|
||||
class _FieldMutation {
|
||||
_FieldMutation(this.name, this.line);
|
||||
///
|
||||
/// The rules judge the facts in [NotifierModel]; the Dart recognisers that
|
||||
/// produce them live in dart_provider_facts.dart.
|
||||
library;
|
||||
|
||||
final String name;
|
||||
final int line;
|
||||
}
|
||||
import 'facts.dart';
|
||||
import 'rules.dart';
|
||||
|
||||
/// A notify wrapper `_notifyListeners()` may be declared in a base class or
|
||||
/// mixin, so it is recognised by naming convention. Anchored on purpose: a
|
||||
|
|
@ -34,670 +27,19 @@ const Set<String> _lifecycleMethods = {
|
|||
'debugFillProperties',
|
||||
};
|
||||
|
||||
class _MethodInfo {
|
||||
_MethodInfo({
|
||||
required this.name,
|
||||
required this.source,
|
||||
required this.offset,
|
||||
required this.calledNames,
|
||||
required this.directNotify,
|
||||
required this.mutations,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String source;
|
||||
final int offset;
|
||||
final Set<String> calledNames;
|
||||
final bool directNotify;
|
||||
final List<_FieldMutation> mutations;
|
||||
}
|
||||
|
||||
class _NotifierInfo {
|
||||
_NotifierInfo({
|
||||
required this.name,
|
||||
required this.file,
|
||||
required this.offset,
|
||||
required this.line,
|
||||
required this.methods,
|
||||
required this.observableFields,
|
||||
required this.apiMembers,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String file;
|
||||
final int offset;
|
||||
final int line;
|
||||
final Map<String, _MethodInfo> methods;
|
||||
|
||||
/// Fields the UI can actually observe: public fields, plus private fields that
|
||||
/// a public getter exposes. Internal bookkeeping (`_disposed`, `_idleTimer`)
|
||||
/// is excluded, because mutating it invisible does not affect the user.
|
||||
final Set<String> observableFields;
|
||||
|
||||
/// The public read surface: public getters and public fields. This is what a
|
||||
/// consumer could display, so a member here that nothing reads is dead state.
|
||||
final Set<String> apiMembers;
|
||||
|
||||
/// Methods that reach a `notifyListeners` call through the class's own calls.
|
||||
final Set<String> notifying = {};
|
||||
|
||||
/// True when the class declares a notify wrapper (e.g. `_notifyListeners`).
|
||||
bool get hasNotifyWrapper =>
|
||||
methods.values.any((m) => m.directNotify) ||
|
||||
methods.keys.any((name) => name.toLowerCase().contains('notify'));
|
||||
}
|
||||
|
||||
class _NotifierCollector extends RecursiveAstVisitor<void> {
|
||||
_NotifierCollector(this.project, this.path, this.lineInfo, this.notifiers);
|
||||
|
||||
final Project project;
|
||||
final String path;
|
||||
final LineInfo lineInfo;
|
||||
final List<_NotifierInfo> notifiers;
|
||||
|
||||
static final RegExp _notify = RegExp(
|
||||
r'notifyListeners',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static bool _isChangeNotifier(ClassDeclaration node) {
|
||||
final superclass = node.extendsClause?.superclass.toSource() ?? '';
|
||||
if (superclass.contains('ChangeNotifier')) return true;
|
||||
return (node.withClause?.mixinTypes ?? const <NamedType>[]).any(
|
||||
(type) => type.toSource().contains('ChangeNotifier'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitClassDeclaration(ClassDeclaration node) {
|
||||
if (!_isChangeNotifier(node)) {
|
||||
super.visitClassDeclaration(node);
|
||||
return;
|
||||
}
|
||||
|
||||
final fields = <String>{};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! FieldDeclaration) continue;
|
||||
for (final variable in member.fields.variables) {
|
||||
fields.add(variable.name.lexeme);
|
||||
}
|
||||
}
|
||||
|
||||
final methods = <String, _MethodInfo>{};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! MethodDeclaration) continue;
|
||||
if (member.isGetter) continue;
|
||||
if (member.isStatic) continue;
|
||||
|
||||
final source = member.toSource();
|
||||
final called = _CalledNames();
|
||||
member.body.accept(called);
|
||||
final mutations = <_FieldMutation>[];
|
||||
for (final field in fields) {
|
||||
// `_x = ...` or `this._x = ...`, but not `==`.
|
||||
final pattern = RegExp(
|
||||
r'(^|[^.\w])(this\.)?' + RegExp.escape(field) + r'\s*=(?!=)',
|
||||
);
|
||||
if (pattern.hasMatch(source)) {
|
||||
mutations.add(
|
||||
_FieldMutation(
|
||||
field,
|
||||
lineInfo.getLocation(member.offset).lineNumber,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
methods[member.name.lexeme] = _MethodInfo(
|
||||
name: member.name.lexeme,
|
||||
source: source,
|
||||
offset: member.offset,
|
||||
calledNames: called.names,
|
||||
directNotify: _notify.hasMatch(source),
|
||||
mutations: mutations,
|
||||
);
|
||||
}
|
||||
|
||||
// What the UI can observe.
|
||||
final observable = <String>{
|
||||
for (final field in fields)
|
||||
if (!field.startsWith('_')) field,
|
||||
};
|
||||
final apiMembers = <String>{
|
||||
for (final field in fields)
|
||||
if (!field.startsWith('_')) field,
|
||||
};
|
||||
for (final member in node.body.members) {
|
||||
if (member is! MethodDeclaration) continue;
|
||||
if (!member.isGetter) continue;
|
||||
if (member.name.lexeme.startsWith('_')) continue;
|
||||
apiMembers.add(member.name.lexeme);
|
||||
final source = member.toSource();
|
||||
for (final field in fields) {
|
||||
if (RegExp(r'\b' + RegExp.escape(field) + r'\b').hasMatch(source)) {
|
||||
observable.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (methods.isNotEmpty) {
|
||||
notifiers.add(
|
||||
_NotifierInfo(
|
||||
name: node.namePart.typeName.lexeme,
|
||||
file: project.relative(path),
|
||||
offset: node.offset,
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
methods: methods,
|
||||
observableFields: observable,
|
||||
apiMembers: apiMembers,
|
||||
),
|
||||
);
|
||||
}
|
||||
super.visitClassDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Method names invoked on `this` (or unqualified) within a body.
|
||||
class _CalledNames extends RecursiveAstVisitor<void> {
|
||||
final Set<String> names = {};
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final target = node.target;
|
||||
if (target == null || target is ThisExpression) {
|
||||
names.add(node.methodName.name);
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
|
||||
final function = node.function;
|
||||
if (function is SimpleIdentifier) names.add(function.name);
|
||||
super.visitFunctionExpressionInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumer sites that read a notifier, used to say whether a Consumer can ever
|
||||
/// rebuild.
|
||||
class _ConsumerCollector extends RecursiveAstVisitor<void> {
|
||||
_ConsumerCollector(this.project, this.path, this.lineInfo, this.consumedBy);
|
||||
|
||||
final Project project;
|
||||
final String path;
|
||||
final LineInfo lineInfo;
|
||||
final Map<String, List<String>> consumedBy;
|
||||
|
||||
static const Set<String> _widgets = {
|
||||
'Consumer',
|
||||
'Selector',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableBuilder',
|
||||
'AnimatedBuilder',
|
||||
};
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
final name = type.name.lexeme;
|
||||
if (!_widgets.contains(name)) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
for (final argument
|
||||
in type.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
|
||||
final typeName = argument.toSource().split('<').first.trim();
|
||||
consumedBy
|
||||
.putIfAbsent(typeName, () => [])
|
||||
.add(
|
||||
'${project.relative(path)}:'
|
||||
'${lineInfo.getLocation(node.offset).lineNumber}',
|
||||
);
|
||||
}
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final name = node.methodName.name;
|
||||
if (name == 'watch' || name == 'read' || name == 'of') {
|
||||
for (final argument
|
||||
in node.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
|
||||
final typeName = argument.toSource().split('<').first.trim();
|
||||
consumedBy
|
||||
.putIfAbsent(typeName, () => [])
|
||||
.add(
|
||||
'${project.relative(path)}:'
|
||||
'${lineInfo.getLocation(node.offset).lineNumber}',
|
||||
);
|
||||
}
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `provider` package provider declaration.
|
||||
class ProviderDeclaration {
|
||||
ProviderDeclaration({
|
||||
required this.kind,
|
||||
required this.valueType,
|
||||
required this.file,
|
||||
required this.line,
|
||||
});
|
||||
|
||||
/// `Provider`, `ChangeNotifierProvider`, `ProxyProvider`, ...
|
||||
final String kind;
|
||||
final String? valueType;
|
||||
final String file;
|
||||
final int line;
|
||||
|
||||
/// Providers whose value is a `Listenable`, so consumers must subscribe.
|
||||
bool get isListenable => const {
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
}.contains(kind);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kind': kind,
|
||||
if (valueType != null) 'valueType': valueType,
|
||||
'file': file,
|
||||
'line': line,
|
||||
};
|
||||
}
|
||||
|
||||
/// Collects `provider` package provider declarations (all provider types).
|
||||
class _ProviderDeclarationCollector extends RecursiveAstVisitor<void> {
|
||||
_ProviderDeclarationCollector(
|
||||
this.project,
|
||||
this.path,
|
||||
this.lineInfo,
|
||||
this.declarations,
|
||||
);
|
||||
|
||||
final Project project;
|
||||
final String path;
|
||||
final LineInfo lineInfo;
|
||||
final List<ProviderDeclaration> declarations;
|
||||
|
||||
static const Set<String> kinds = {
|
||||
'Provider',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
'FutureProvider',
|
||||
'StreamProvider',
|
||||
'StateProvider',
|
||||
'ProxyProvider',
|
||||
'ChangeNotifierProxyProvider',
|
||||
'InheritedProvider',
|
||||
};
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
var kind = type.name.lexeme;
|
||||
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
|
||||
if (!kinds.contains(kind)) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// The provider package takes `create:`/`update:`; Riverpod passes a
|
||||
// positional `(ref)` callback. That plus a `package:provider` import keeps
|
||||
// the two apart.
|
||||
final named = <String>{};
|
||||
for (final argument in node.argumentList.arguments) {
|
||||
if (argument is NamedArgument) named.add(argument.name.lexeme);
|
||||
}
|
||||
final looksLikeRiverpod =
|
||||
!named.contains('create') &&
|
||||
!named.contains('update') &&
|
||||
!named.contains('value') &&
|
||||
node.constructorName.name == null;
|
||||
if (looksLikeRiverpod) {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
|
||||
final arguments = type.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
declarations.add(
|
||||
ProviderDeclaration(
|
||||
kind: kind,
|
||||
valueType: arguments.isEmpty
|
||||
? null
|
||||
: arguments.first.toSource().split('<').first.trim(),
|
||||
file: project.relative(path),
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
),
|
||||
);
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds `context.read<T>()` / `Provider.of<T>(context, listen: false)` used
|
||||
/// inside a `build` method for a type that is a `Listenable`.
|
||||
///
|
||||
/// `read` never subscribes, so a widget that displays such a value will not
|
||||
/// rebuild when it changes. This targets ChangeNotifier-provided state, which is
|
||||
/// why it only fires for listenable provider types or known ChangeNotifiers.
|
||||
class _ReadInBuildCollector extends RecursiveAstVisitor<void> {
|
||||
_ReadInBuildCollector(
|
||||
this.project,
|
||||
this.path,
|
||||
this.lineInfo,
|
||||
this.listenableTypes,
|
||||
this.notifierNames,
|
||||
this.findings,
|
||||
);
|
||||
|
||||
final Project project;
|
||||
final String path;
|
||||
final LineInfo lineInfo;
|
||||
final Set<String> listenableTypes;
|
||||
final Set<String> notifierNames;
|
||||
final List<Finding> findings;
|
||||
|
||||
bool _isBuildMethod(MethodDeclaration node) {
|
||||
if (node.name.lexeme != 'build') return false;
|
||||
final returnType = node.returnType?.toSource() ?? '';
|
||||
if (returnType.contains('Widget')) return true;
|
||||
final parameters = node.parameters;
|
||||
if (parameters == null) return false;
|
||||
for (final parameter in parameters.parameters) {
|
||||
if (parameter.toSource().contains('BuildContext')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isListened(String type) =>
|
||||
listenableTypes.contains(type) || notifierNames.contains(type);
|
||||
|
||||
@override
|
||||
void visitMethodDeclaration(MethodDeclaration node) {
|
||||
if (!_isBuildMethod(node)) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
final collector = _ReadCallCollector();
|
||||
node.body.accept(collector);
|
||||
for (final call in collector.calls) {
|
||||
final type = call.type;
|
||||
if (!_isListened(type)) continue;
|
||||
findings.add(
|
||||
Finding(
|
||||
rule: ruleProviderReadInBuild,
|
||||
severity: PipeSeverity.warning,
|
||||
confidence: 'medium',
|
||||
message:
|
||||
'$type is read without subscribing inside build '
|
||||
'(${call.source}). This widget will not rebuild when $type '
|
||||
'changes, so anything it renders from that value can go stale.',
|
||||
file: project.relative(path),
|
||||
line: lineInfo.getLocation(call.offset).lineNumber,
|
||||
evidence: [
|
||||
'${project.relative(path)}:'
|
||||
'${lineInfo.getLocation(call.offset).lineNumber}: '
|
||||
'${call.source}',
|
||||
],
|
||||
remediation:
|
||||
'Use context.watch<$type>() (or Provider.of<$type>(context)) so '
|
||||
'the widget subscribes — or keep read() and make sure nothing '
|
||||
'rendered depends on the value.',
|
||||
data: {'valueType': type, 'call': call.source, 'member': 'build'},
|
||||
),
|
||||
);
|
||||
}
|
||||
// Still descend: a nested widget may have its own build method.
|
||||
super.visitMethodDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadCall {
|
||||
_ReadCall(this.type, this.offset, this.source);
|
||||
|
||||
final String type;
|
||||
final int offset;
|
||||
final String source;
|
||||
}
|
||||
|
||||
/// Collects non-subscribing reads made directly in a build body.
|
||||
///
|
||||
/// It deliberately does NOT descend into nested functions: a read inside a
|
||||
/// closure (a button callback, a BlocListener listener, a Selector builder) is
|
||||
/// not made while the build method runs, so not subscribing there is usually
|
||||
/// exactly right. Hand-checking the first real findings showed every one of them
|
||||
/// was a closure read, which is why this is a precision guard rather than an
|
||||
/// oversight - it costs recall on reads inside a `Builder(builder: ...)`.
|
||||
class _ReadCallCollector extends RecursiveAstVisitor<void> {
|
||||
final List<_ReadCall> calls = [];
|
||||
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
@override
|
||||
void visitMethodInvocation(MethodInvocation node) {
|
||||
final arguments = node.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
if (arguments.isNotEmpty) {
|
||||
final type = arguments.first.toSource().split('<').first.trim();
|
||||
final name = node.methodName.name;
|
||||
final target = node.target?.toSource();
|
||||
|
||||
// context.read<T>()
|
||||
if (name == 'read' && (target == 'context' || target == 'this.context')) {
|
||||
calls.add(_ReadCall(type, node.offset, node.toSource()));
|
||||
}
|
||||
|
||||
// Provider.of<T>(context, listen: false)
|
||||
if (name == 'of' && (target == 'Provider' || target == 'provider')) {
|
||||
final listens = node.argumentList.arguments.any(
|
||||
(argument) =>
|
||||
argument is NamedArgument &&
|
||||
argument.name.lexeme == 'listen' &&
|
||||
argument.argumentExpression.toSource().trim() == 'false',
|
||||
);
|
||||
if (listens) calls.add(_ReadCall(type, node.offset, node.toSource()));
|
||||
}
|
||||
}
|
||||
super.visitMethodInvocation(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Member names a user would notice going unrendered.
|
||||
final RegExp _looksImportantMember = RegExp(
|
||||
r'(error|failure|message|loading|empty|count|total|status)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Collects member reads (`x.someGetter`, `x.someField`) per file, so a public
|
||||
/// notifier getter that nothing reads can be identified as dead state.
|
||||
class _MemberUseCollector extends RecursiveAstVisitor<void> {
|
||||
_MemberUseCollector(this.uses);
|
||||
|
||||
final Set<String> uses;
|
||||
|
||||
@override
|
||||
void visitPropertyAccess(PropertyAccess node) {
|
||||
uses.add(node.propertyName.name);
|
||||
super.visitPropertyAccess(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
||||
uses.add(node.identifier.name);
|
||||
super.visitPrefixedIdentifier(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a provider constructed directly inside a build method.
|
||||
///
|
||||
/// Without `.value` the provider's `create` callback runs on every rebuild, so a
|
||||
/// new notifier (and a new listener registration) is created each time and any
|
||||
/// state it held is lost.
|
||||
class _ProviderInBuildCollector extends RecursiveAstVisitor<void> {
|
||||
_ProviderInBuildCollector(
|
||||
this.project,
|
||||
this.path,
|
||||
this.lineInfo,
|
||||
this.findings,
|
||||
);
|
||||
|
||||
final Project project;
|
||||
final String path;
|
||||
final LineInfo lineInfo;
|
||||
final List<Finding> findings;
|
||||
final List<String> _classStack = [];
|
||||
|
||||
static const Set<String> kinds = {
|
||||
'Provider',
|
||||
'ChangeNotifierProvider',
|
||||
'ListenableProvider',
|
||||
'ValueListenableProvider',
|
||||
'ProxyProvider',
|
||||
'ChangeNotifierProxyProvider',
|
||||
};
|
||||
|
||||
bool _isBuildMethod(MethodDeclaration node) {
|
||||
if (node.name.lexeme != 'build') return false;
|
||||
if ((node.returnType?.toSource() ?? '').contains('Widget')) return true;
|
||||
final parameters = node.parameters;
|
||||
if (parameters == null) return false;
|
||||
return parameters.parameters.any(
|
||||
(parameter) => parameter.toSource().contains('BuildContext'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitClassDeclaration(ClassDeclaration node) {
|
||||
_classStack.add(node.namePart.typeName.lexeme);
|
||||
super.visitClassDeclaration(node);
|
||||
_classStack.removeLast();
|
||||
}
|
||||
|
||||
@override
|
||||
void visitMethodDeclaration(MethodDeclaration node) {
|
||||
if (!_isBuildMethod(node)) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
final collector = _ProviderCreationCollector();
|
||||
node.body.accept(collector);
|
||||
if (collector.creations.isEmpty) {
|
||||
super.visitMethodDeclaration(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// One finding per build method, not one per provider: on a real app root
|
||||
// this is a single decision (hoist them or not), and 45 findings for one
|
||||
// root widget buries everything else. The claim is factual - create does
|
||||
// rerun on rebuild - but whether it matters depends on how often the widget
|
||||
// rebuilds, which is why this is info and not a warning.
|
||||
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
|
||||
final kinds = <String, int>{};
|
||||
final valueTypes = <String>[];
|
||||
for (final creation in collector.creations) {
|
||||
kinds.update(creation.kind, (v) => v + 1, ifAbsent: () => 1);
|
||||
if (!valueTypes.contains(creation.valueType)) {
|
||||
valueTypes.add(creation.valueType);
|
||||
}
|
||||
}
|
||||
valueTypes.sort();
|
||||
final summary = kinds.entries.map((e) => '${e.key} x${e.value}').join(', ');
|
||||
final first = collector.creations.first;
|
||||
|
||||
findings.add(
|
||||
Finding(
|
||||
rule: ruleProviderCreatedInBuild,
|
||||
severity: PipeSeverity.info,
|
||||
confidence: 'medium',
|
||||
message:
|
||||
'$owner.build constructs ${collector.creations.length} provider(s) '
|
||||
'without `.value` ($summary), so their create callbacks run again if '
|
||||
'this widget rebuilds - new instances and listener registrations, '
|
||||
'and any state they held is lost.',
|
||||
file: project.relative(path),
|
||||
line: lineInfo.getLocation(node.offset).lineNumber,
|
||||
evidence: [
|
||||
for (final creation in collector.creations.take(3))
|
||||
'${project.relative(path)}:'
|
||||
'${lineInfo.getLocation(creation.offset).lineNumber}: '
|
||||
'${creation.kind}<${creation.valueType}>',
|
||||
],
|
||||
remediation:
|
||||
'Hoist these above the widget that rebuilds (a parent build or the '
|
||||
'app root), or use the `.value` constructor with instances created '
|
||||
'once. Harmless only if this widget never rebuilds.',
|
||||
data: {
|
||||
'owner': owner,
|
||||
'member': 'build',
|
||||
'kindCounts': summary,
|
||||
'valueTypes': valueTypes,
|
||||
'providerCount': collector.creations.length,
|
||||
'firstKind': first.kind,
|
||||
},
|
||||
),
|
||||
);
|
||||
super.visitMethodDeclaration(node);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderCreation {
|
||||
_ProviderCreation(this.kind, this.valueType, this.offset, this.source);
|
||||
|
||||
final String kind;
|
||||
final String valueType;
|
||||
final int offset;
|
||||
final String source;
|
||||
}
|
||||
|
||||
class _ProviderCreationCollector extends RecursiveAstVisitor<void> {
|
||||
final List<_ProviderCreation> creations = [];
|
||||
|
||||
// A closure runs later, so a provider created there is a different problem.
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
@override
|
||||
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
||||
final type = node.constructorName.type;
|
||||
var kind = type.name.lexeme;
|
||||
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
|
||||
// `.value` passes an existing instance, which is correct.
|
||||
if (node.constructorName.name?.name == 'value') {
|
||||
super.visitInstanceCreationExpression(node);
|
||||
return;
|
||||
}
|
||||
if (_ProviderInBuildCollector.kinds.contains(kind)) {
|
||||
final arguments =
|
||||
type.typeArguments?.arguments ?? const <TypeAnnotation>[];
|
||||
creations.add(
|
||||
_ProviderCreation(
|
||||
kind,
|
||||
arguments.isEmpty
|
||||
? '?'
|
||||
: arguments.first.toSource().split('<').first.trim(),
|
||||
node.offset,
|
||||
node.toSource(),
|
||||
),
|
||||
);
|
||||
}
|
||||
super.visitInstanceCreationExpression(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `ChangeNotifier` findings to [findings].
|
||||
///
|
||||
/// Returns a coverage note so that "0 findings" can be told apart from "the rule
|
||||
/// never ran": it reports how many notifier classes and candidate mutators were
|
||||
/// actually examined.
|
||||
String runProviderRules({
|
||||
required Project project,
|
||||
required List<FileOutcome> outcomes,
|
||||
required NotifierModel model,
|
||||
required List<Finding> findings,
|
||||
required Set<String> enabledRules,
|
||||
FixPolicy policy = FixPolicy.wireUp,
|
||||
|
|
@ -715,44 +57,25 @@ String runProviderRules({
|
|||
return '';
|
||||
}
|
||||
|
||||
final notifiers = <_NotifierInfo>[];
|
||||
final consumedBy = <String, List<String>>{};
|
||||
final declarations = <ProviderDeclaration>[];
|
||||
final memberUses = <String, Set<String>>{};
|
||||
for (final outcome in outcomes) {
|
||||
final unit = outcome.unit;
|
||||
if (unit == null) continue;
|
||||
unit.unit.accept(
|
||||
_NotifierCollector(project, outcome.path, unit.lineInfo, notifiers),
|
||||
);
|
||||
unit.unit.accept(
|
||||
_ConsumerCollector(project, outcome.path, unit.lineInfo, consumedBy),
|
||||
);
|
||||
unit.unit.accept(
|
||||
_ProviderDeclarationCollector(
|
||||
project,
|
||||
outcome.path,
|
||||
unit.lineInfo,
|
||||
declarations,
|
||||
),
|
||||
);
|
||||
final uses = <String>{};
|
||||
unit.unit.accept(_MemberUseCollector(uses));
|
||||
memberUses[project.relative(outcome.path)] = uses;
|
||||
}
|
||||
final notifiers = model.notifiers;
|
||||
final consumedBy = model.consumedBy;
|
||||
final declarations = model.declarations;
|
||||
final memberUses = model.memberUses;
|
||||
|
||||
// A method notifies if it calls notifyListeners or calls a notifying method.
|
||||
final notifyingOf = <NotifierFact, Set<String>>{};
|
||||
for (final notifier in notifiers) {
|
||||
final notifying = notifyingOf[notifier] = <String>{};
|
||||
final stack = <String>[
|
||||
for (final method in notifier.methods.values)
|
||||
for (final method in notifier.methods)
|
||||
if (method.directNotify) method.name,
|
||||
for (final name in notifier.methods.keys)
|
||||
if (_notifyWrapperName.hasMatch(name)) name,
|
||||
for (final method in notifier.methods)
|
||||
if (_notifyWrapperName.hasMatch(method.name)) method.name,
|
||||
];
|
||||
while (stack.isNotEmpty) {
|
||||
final name = stack.removeLast();
|
||||
if (!notifier.notifying.add(name)) continue;
|
||||
for (final method in notifier.methods.values) {
|
||||
if (!notifying.add(name)) continue;
|
||||
for (final method in notifier.methods) {
|
||||
if (method.calledNames.contains(name)) stack.add(method.name);
|
||||
}
|
||||
}
|
||||
|
|
@ -766,21 +89,30 @@ String runProviderRules({
|
|||
declaration.valueType!,
|
||||
};
|
||||
final notifierNames = {for (final notifier in notifiers) notifier.name};
|
||||
if (listenableTypes.isNotEmpty || notifierNames.isNotEmpty) {
|
||||
for (final outcome in outcomes) {
|
||||
final unit = outcome.unit;
|
||||
if (unit == null) continue;
|
||||
unit.unit.accept(
|
||||
_ReadInBuildCollector(
|
||||
project,
|
||||
outcome.path,
|
||||
unit.lineInfo,
|
||||
listenableTypes,
|
||||
notifierNames,
|
||||
findings,
|
||||
),
|
||||
);
|
||||
}
|
||||
bool isListened(String type) =>
|
||||
listenableTypes.contains(type) || notifierNames.contains(type);
|
||||
for (final read in model.nonSubscribingReads) {
|
||||
final type = read.type;
|
||||
if (!isListened(type)) continue;
|
||||
findings.add(
|
||||
Finding(
|
||||
rule: ruleProviderReadInBuild,
|
||||
severity: PipeSeverity.warning,
|
||||
confidence: 'medium',
|
||||
message:
|
||||
'$type is read without subscribing inside build '
|
||||
'(${read.call}). This widget will not rebuild when $type '
|
||||
'changes, so anything it renders from that value can go stale.',
|
||||
file: read.path,
|
||||
line: read.line,
|
||||
evidence: ['${read.path}:${read.line}: ${read.call}'],
|
||||
remediation:
|
||||
'Use context.watch<$type>() (or Provider.of<$type>(context)) so '
|
||||
'the widget subscribes — or keep read() and make sure nothing '
|
||||
'rendered depends on the value.',
|
||||
data: {'valueType': type, 'call': read.call, 'member': 'build'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -834,15 +166,54 @@ String runProviderRules({
|
|||
|
||||
// R013 - a provider constructed inside build.
|
||||
if (wantCreatedInBuild) {
|
||||
for (final outcome in outcomes) {
|
||||
final unit = outcome.unit;
|
||||
if (unit == null) continue;
|
||||
unit.unit.accept(
|
||||
_ProviderInBuildCollector(
|
||||
project,
|
||||
outcome.path,
|
||||
unit.lineInfo,
|
||||
findings,
|
||||
for (final build in model.createdInBuild) {
|
||||
// One finding per build method, not one per provider: on a real app root
|
||||
// this is a single decision (hoist them or not), and 45 findings for one
|
||||
// root widget buries everything else. The claim is factual - create does
|
||||
// rerun on rebuild - but whether it matters depends on how often the
|
||||
// widget rebuilds, which is why this is info and not a warning.
|
||||
final kinds = <String, int>{};
|
||||
final valueTypes = <String>[];
|
||||
for (final creation in build.creations) {
|
||||
kinds.update(creation.kind, (v) => v + 1, ifAbsent: () => 1);
|
||||
if (!valueTypes.contains(creation.valueType)) {
|
||||
valueTypes.add(creation.valueType);
|
||||
}
|
||||
}
|
||||
valueTypes.sort();
|
||||
final summary = kinds.entries
|
||||
.map((e) => '${e.key} x${e.value}')
|
||||
.join(', ');
|
||||
final first = build.creations.first;
|
||||
findings.add(
|
||||
Finding(
|
||||
rule: ruleProviderCreatedInBuild,
|
||||
severity: PipeSeverity.info,
|
||||
confidence: 'medium',
|
||||
message:
|
||||
'${build.owner}.build constructs ${build.creations.length} '
|
||||
'provider(s) without `.value` ($summary), so their create '
|
||||
'callbacks run again if this widget rebuilds - new instances and '
|
||||
'listener registrations, and any state they held is lost.',
|
||||
file: build.path,
|
||||
line: build.line,
|
||||
evidence: [
|
||||
for (final creation in build.creations.take(3))
|
||||
'${build.path}:${creation.line}: '
|
||||
'${creation.kind}<${creation.valueType}>',
|
||||
],
|
||||
remediation:
|
||||
'Hoist these above the widget that rebuilds (a parent build or '
|
||||
'the app root), or use the `.value` constructor with instances '
|
||||
'created once. Harmless only if this widget never rebuilds.',
|
||||
data: {
|
||||
'owner': build.owner,
|
||||
'member': 'build',
|
||||
'kindCounts': summary,
|
||||
'valueTypes': valueTypes,
|
||||
'providerCount': build.creations.length,
|
||||
'firstKind': first.kind,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -854,7 +225,8 @@ String runProviderRules({
|
|||
final consumers = consumedBy[notifier.name] ?? const <String>[];
|
||||
final isConsumed = consumers.isNotEmpty;
|
||||
if (isConsumed) consumedCount++;
|
||||
final inert = isConsumed && notifier.notifying.isEmpty;
|
||||
final notifying = notifyingOf[notifier]!;
|
||||
final inert = isConsumed && notifying.isEmpty;
|
||||
|
||||
// When nothing in the notifier ever notifies, one class-level finding beats
|
||||
// one per mutator, so the consumer-facing rule covers it instead.
|
||||
|
|
@ -880,7 +252,7 @@ String runProviderRules({
|
|||
}
|
||||
|
||||
if (!wantNeverNotifies) continue;
|
||||
for (final method in notifier.methods.values) {
|
||||
for (final method in notifier.methods) {
|
||||
if (method.mutations.isEmpty) continue;
|
||||
// Lifecycle teardown must not notify: notifying after dispose throws, so
|
||||
// a missing notify here is correct, not a bug.
|
||||
|
|
@ -891,15 +263,15 @@ String runProviderRules({
|
|||
.toList();
|
||||
if (observable.isEmpty) continue;
|
||||
candidates++;
|
||||
if (notifier.notifying.contains(method.name)) continue;
|
||||
if (notifying.contains(method.name)) continue;
|
||||
// The UI entry points are the public methods; private helpers are an
|
||||
// implementation detail of some other path.
|
||||
if (method.name.startsWith('_')) continue;
|
||||
// If a notifying method calls this one, the caller is responsible for
|
||||
// announcing the change, so this is not a missing notification.
|
||||
final calledByNotifier = notifier.methods.values.any(
|
||||
final calledByNotifier = notifier.methods.any(
|
||||
(caller) =>
|
||||
notifier.notifying.contains(caller.name) &&
|
||||
notifying.contains(caller.name) &&
|
||||
caller.calledNames.contains(method.name),
|
||||
);
|
||||
if (calledByNotifier) continue;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'analysis.dart';
|
||||
import 'errors.dart';
|
||||
import 'project.dart';
|
||||
import 'provider_rules.dart';
|
||||
import 'state_flow.dart';
|
||||
|
||||
|
|
@ -315,9 +313,10 @@ bool _looksLoading(String variant) => RegExp(
|
|||
).hasMatch(variant);
|
||||
|
||||
/// Build a finding per rule over an already-built state-flow graph.
|
||||
///
|
||||
/// Rules read only [flow]: no syntax tree, no resolved unit. Any front end that
|
||||
/// can produce a [StateFlow] (or its JSON) gets every rule.
|
||||
FindingReport runChecks({
|
||||
required Project project,
|
||||
required List<FileOutcome> outcomes,
|
||||
required StateFlow flow,
|
||||
Set<String>? enabledRules,
|
||||
Set<String>? baseline,
|
||||
|
|
@ -685,7 +684,7 @@ FindingReport runChecks({
|
|||
confidence: 'high',
|
||||
message:
|
||||
'${union.owner} handles $event, but nothing under '
|
||||
'${project.directories.join(', ')} ever dispatches or '
|
||||
'${flow.directories.join(', ')} ever dispatches or '
|
||||
'constructs $event.',
|
||||
file: registration.path,
|
||||
line: registration.line,
|
||||
|
|
@ -845,8 +844,7 @@ FindingReport runChecks({
|
|||
enabled.contains(ruleNotifierFieldUnread) ||
|
||||
enabled.contains(ruleProviderCreatedInBuild)) {
|
||||
final note = runProviderRules(
|
||||
project: project,
|
||||
outcomes: outcomes,
|
||||
model: flow.notifierModel,
|
||||
findings: findings,
|
||||
enabledRules: enabled,
|
||||
policy: policy,
|
||||
|
|
@ -902,7 +900,7 @@ FindingReport runChecks({
|
|||
});
|
||||
|
||||
return FindingReport(
|
||||
root: project.root,
|
||||
root: flow.root,
|
||||
findings: resolved,
|
||||
suppressed: suppressed,
|
||||
notes: notes,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:analyzer/source/line_info.dart';
|
|||
|
||||
import 'analysis.dart';
|
||||
import 'dart_facts.dart';
|
||||
import 'dart_provider_facts.dart';
|
||||
import 'facts.dart';
|
||||
import 'project.dart';
|
||||
|
||||
|
|
@ -356,14 +357,21 @@ class StateFlow {
|
|||
required this.ownerReferences,
|
||||
required this.flagStates,
|
||||
required this.riverpod,
|
||||
this.directories = const ['lib'],
|
||||
this.constructedByFile = const {},
|
||||
this.swallowedFailures = const [],
|
||||
this.unsettledFlags = const [],
|
||||
UnresolvedSummary? unresolved,
|
||||
NotifierModel? notifierModel,
|
||||
}) : unresolved =
|
||||
unresolved ?? UnresolvedSummary(files: const [], analysed: 0);
|
||||
unresolved ?? UnresolvedSummary(files: const [], analysed: 0),
|
||||
notifierModel = notifierModel ?? NotifierModel();
|
||||
|
||||
final String root;
|
||||
|
||||
/// Directories the front end scanned, so a claim like "nothing dispatches X"
|
||||
/// can say where it looked.
|
||||
final List<String> directories;
|
||||
final List<StateUnion> unions;
|
||||
final List<EventUnion> eventUnions;
|
||||
final List<EmissionSite> emissions;
|
||||
|
|
@ -396,8 +404,12 @@ class StateFlow {
|
|||
/// Files whose references did not resolve, and the names they mention.
|
||||
final UnresolvedSummary unresolved;
|
||||
|
||||
/// ChangeNotifier / provider facts.
|
||||
final NotifierModel notifierModel;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'root': root,
|
||||
'directories': directories,
|
||||
'totals': {
|
||||
'unions': unions.length,
|
||||
'variants': unions.fold<int>(
|
||||
|
|
@ -427,6 +439,7 @@ class StateFlow {
|
|||
'swallowedFailures': [for (final fact in swallowedFailures) fact.toJson()],
|
||||
'unsettledFlags': [for (final fact in unsettledFlags) fact.toJson()],
|
||||
'unresolved': unresolved.toJson(),
|
||||
'notifiers': notifierModel.toJson(),
|
||||
};
|
||||
|
||||
/// Human summary, ordered by the most suspicious signal first.
|
||||
|
|
@ -1926,6 +1939,7 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
|
|||
|
||||
return StateFlow(
|
||||
root: project.root,
|
||||
directories: project.directories,
|
||||
unions: unions,
|
||||
eventUnions: eventUnions,
|
||||
emissions: emissions,
|
||||
|
|
@ -1940,6 +1954,7 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
|
|||
swallowedFailures: swallowedFailures,
|
||||
unsettledFlags: unsettledFlags,
|
||||
unresolved: _unresolvedSummary(project, outcomes),
|
||||
notifierModel: collectNotifierModel(project, unitsByPath),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@ void main() {
|
|||
await analysis.dispose();
|
||||
});
|
||||
|
||||
FindingReport reportWith(Set<String>? baseline) => runChecks(
|
||||
project: analysis.project,
|
||||
outcomes: analysis.run.outcomes,
|
||||
flow: analysis.flow,
|
||||
baseline: baseline,
|
||||
);
|
||||
FindingReport reportWith(Set<String>? baseline) =>
|
||||
runChecks(flow: analysis.flow, baseline: baseline);
|
||||
|
||||
test('encodes a baseline whose fingerprints cover every finding', () {
|
||||
final encoded = encodeBaseline(
|
||||
|
|
|
|||
|
|
@ -19,12 +19,8 @@ void main() {
|
|||
await analysis.dispose();
|
||||
});
|
||||
|
||||
FindingReport reportWith(FixPolicy policy) => runChecks(
|
||||
project: analysis.project,
|
||||
outcomes: analysis.run.outcomes,
|
||||
flow: analysis.flow,
|
||||
policy: policy,
|
||||
);
|
||||
FindingReport reportWith(FixPolicy policy) =>
|
||||
runChecks(flow: analysis.flow, policy: policy);
|
||||
|
||||
String briefWith(FixPolicy policy) => buildGuidance(
|
||||
root: '/root',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ void main() {
|
|||
project = await loadProject(fixturePath);
|
||||
run = await resolveProject(project, concurrency: 1);
|
||||
flow = buildStateFlow(project, run.outcomes);
|
||||
report = runChecks(project: project, outcomes: run.outcomes, flow: flow);
|
||||
report = runChecks(flow: flow);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue