merge/audit-and-state-map #7

Merged
trevon merged 15 commits from merge/audit-and-state-map into main 2026-09-25 00:07:04 +00:00
10 changed files with 955 additions and 749 deletions
Showing only changes of commit c3f1fb6319 - Show all commits

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.
trvon 2026-09-22 14:23:39 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -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;

View 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,
);
}

View file

@ -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,
};
}

View file

@ -55,8 +55,6 @@ Future<ProjectAnalysis> analyzeProject(
);
}
final report = runChecks(
project: project,
outcomes: run.outcomes,
flow: flow,
enabledRules: enabledRules,
baseline: baseline,

View file

@ -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;

View file

@ -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,

View file

@ -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),
);
}

View file

@ -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(

View file

@ -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',

View file

@ -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 {