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
4 changed files with 691 additions and 485 deletions
Showing only changes of commit 56dcfc85a6 - Show all commits

refactor(rules): judge loading and construction facts, not syntax trees

First step of the state-map seam: rules should read only a
language-neutral model, so a second front end (C++) can feed them.

The two loading rules built findings inside tree visitors, and
dead-handler and the unresolved-reference check walked every unit from
runChecks. Their recognisers now live in the Dart front end
(dart_facts.dart) and emit neutral facts (facts.dart) carried on
StateFlow:

- SwallowedFailure: a busy variant is emitted, then an awaited failure is
  swallowed (loading-not-reset). The "does this owner hold rendered
  state" gate moved to the rule, since it is judgement, not recognition.
- UnsettledFlag: a loading flag left set across unguarded awaited work
  (loading-flag-not-reset); the rule derives confidence from setsFlag and
  guardedTrySeen.
- constructedByFile and UnresolvedSummary, with project-relative paths.

Every fact serialises to JSON and appears in `pipe state`. rules.dart no
longer imports any analyzer AST library; only the provider rules still
take resolved units, which is the next step.

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:17:33 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

409
lib/src/dart_facts.dart Normal file
View file

@ -0,0 +1,409 @@
/// The Dart front end's pattern recognisers for the loading rules.
///
/// These walk syntax trees and emit the language-neutral facts in facts.dart;
/// the rules in rules.dart judge the facts and never see a tree.
library;
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 'state_flow.dart';
/// Type names constructed in a unit, so a handler is not called dead when its
/// event is built somewhere and dispatched through a variable.
class ConstructionCollector extends RecursiveAstVisitor<void> {
final Set<String> constructed = {};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
constructed.add(node.constructorName.type.name.lexeme);
super.visitInstanceCreationExpression(node);
}
}
class _TryCollector extends RecursiveAstVisitor<void> {
final List<TryStatement> tries = [];
@override
void visitTryStatement(TryStatement node) {
tries.add(node);
super.visitTryStatement(node);
}
// Nested functions are analysed on their own.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
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);
}
}
/// `on<E>` for a bloc handler closure, so two handlers in one class get
/// distinct identities. As a bare `<closure>` they shared a fingerprint, and
/// the baseline dropped all but one of them.
String _closureMemberName(FunctionExpression node) {
final list = node.parent;
final call = list?.parent;
if (list is ArgumentList && call is MethodInvocation) {
final types = call.typeArguments?.arguments;
if (call.methodName.name == 'on' && types != null && types.length == 1) {
return 'on<${types.single.toSource()}>';
}
}
return '<closure>';
}
/// Finds awaited work on a loading-flag path that no catch clause resolves.
class UnsettledFlagCollector extends RecursiveAstVisitor<void> {
UnsettledFlagCollector(
this.relativePath,
this.lineInfo,
this.flagStatesByOwner,
this.facts,
);
final String relativePath;
final LineInfo lineInfo;
final Map<String, FlagState> flagStatesByOwner;
final List<UnsettledFlag> facts;
final List<String> _classStack = [];
/// Methods invoked from a constructor body: for those, a default-true loading
/// flag is in effect even though the method never sets it itself.
final Map<String, Set<String>> _constructorCalls = {};
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
static bool _surfacesFailure(String source) {
if (source.contains('emit(') || source.contains('notifyListeners(')) {
return true;
}
if (_stateAssign.hasMatch(source)) return true;
if (_throwing.hasMatch(source)) return true;
return source.contains('add(') && _errorish.hasMatch(source);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
final name = node.namePart.typeName.lexeme;
final calls = <String>{};
for (final member in node.body.members) {
if (member is! ConstructorDeclaration) continue;
final collector = _CalledNames();
member.body.accept(collector);
calls.addAll(collector.names);
}
if (calls.isNotEmpty) _constructorCalls[name] = calls;
_classStack.add(name);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final flag = flagStatesByOwner[owner];
if (flag == null) return;
// Lifecycle teardown never renders a loading state.
if (member == 'close' || member == 'dispose') return;
// Only methods that actually put the loading flag into the true state can
// leave the UI spinning. A method that merely runs in a class whose state
// has such a flag (a repository write, a teardown call) cannot.
//
// Every probe here stops at nested functions, which are analysed on their
// own. The flag probe and the await probe used to read closure bodies while
// the try collector skipped them, so a method whose closure set the flag
// and guarded its own await was reported at high confidence.
final flagProbe = _SetsFlagTrue(flag.fields.toSet());
body.accept(flagProbe);
final setsFlagTrue = flagProbe.found;
final inheritsInitialFlag =
flag.defaultsTrue &&
(_constructorCalls[owner]?.contains(member) ?? false);
if (!setsFlagTrue && !inheritsInitialFlag) return;
// Guarded when an awaited try has a catch that surfaces the failure.
final tries = _TryCollector();
body.accept(tries);
var awaitedTryGuarded = false;
var awaitedTrySeen = false;
for (final statement in tries.tries) {
final awaitedInTry = _HasAwait(shallow: true);
statement.body.accept(awaitedInTry);
if (!awaitedInTry.found) continue;
awaitedTrySeen = true;
for (final catchClause in statement.catchClauses) {
if (_surfacesFailure(catchClause.body.toSource())) {
awaitedTryGuarded = true;
}
}
}
if (awaitedTryGuarded) return;
// Nothing awaited at all means there is no failure path to leave dangling.
final awaitProbe = _HasAwait(shallow: true);
body.accept(awaitProbe);
if (!awaitProbe.found) return;
facts.add(
UnsettledFlag(
owner: owner,
member: member,
path: relativePath,
line: lineInfo.getLocation(body.offset).lineNumber,
stateType: flag.name,
fields: flag.fields,
defaultsTrue: flag.defaultsTrue,
setsFlag: setsFlagTrue,
guardedTrySeen: awaitedTrySeen,
),
);
}
}
/// Scans a single function body for in-flight loading emits and top-level
/// awaited try statements.
class _BodyScan extends RecursiveAstVisitor<void> {
final List<int> loadingEmits = [];
final List<TryStatement> topLevelTries = [];
static final RegExp _loading = RegExp(
r'(Loading|Processing|Submitting|Saving|Creating|Updating|InProgress)',
);
int _tryDepth = 0;
void _maybeLoading(AstNode node, List<Argument> arguments) {
if (arguments.isEmpty) return;
final first = arguments.first;
if (first is! Expression) return;
if (_loading.hasMatch(first.toSource())) loadingEmits.add(node.offset);
}
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier && function.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitFunctionExpressionInvocation(node);
}
@override
void visitTryStatement(TryStatement node) {
final isTopLevel = _tryDepth == 0;
_tryDepth++;
if (isTopLevel) topLevelTries.add(node);
super.visitTryStatement(node);
_tryDepth--;
}
// Nested functions are analysed in their own right by _analyze; descending
// into them here would report the same catch twice.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _HasAwait extends RecursiveAstVisitor<void> {
_HasAwait({this.shallow = false});
/// Stop at nested functions: an await inside a closure is not awaited by the
/// body that declares the closure.
final bool shallow;
bool found = false;
@override
void visitAwaitExpression(AwaitExpression node) {
found = true;
super.visitAwaitExpression(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
if (!shallow) super.visitFunctionExpression(node);
}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
if (!shallow) super.visitFunctionDeclaration(node);
}
}
/// A `field: true` named argument for one of the loading fields, outside any
/// nested function.
class _SetsFlagTrue extends RecursiveAstVisitor<void> {
_SetsFlagTrue(this.fields);
final Set<String> fields;
bool found = false;
@override
void visitNamedArgument(NamedArgument node) {
final value = node.argumentExpression;
if (fields.contains(node.name.lexeme) &&
value is BooleanLiteral &&
value.value) {
found = true;
}
super.visitNamedArgument(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
/// Finds members that emit an in-progress variant and then await work whose
/// catch writes no state. Whether the owner holds rendered state is the rule's
/// judgement, not the front end's, so every owner is scanned.
class SwallowedFailureCollector extends RecursiveAstVisitor<void> {
SwallowedFailureCollector(this.relativePath, this.lineInfo, this.facts);
final String relativePath;
final LineInfo lineInfo;
final List<SwallowedFailure> facts;
final List<String> _classStack = [];
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final scan = _BodyScan();
body.accept(scan);
if (scan.loadingEmits.isEmpty) return;
// Position within the member, not the line: two swallowed catches in one
// method need distinct fingerprints, and a line number changed whenever
// anything above the method moved.
var swallowed = 0;
for (final statement in scan.topLevelTries) {
if (statement.catchClauses.isEmpty) continue;
final probe = _HasAwait();
statement.body.accept(probe);
if (!probe.found) continue;
for (final catchClause in statement.catchClauses) {
final source = catchClause.body.toSource();
final emits =
source.contains('emit(') || source.contains('notifyListeners(');
final assignsState = _stateAssign.hasMatch(source);
final rethrows = _throwing.hasMatch(source);
final dispatchesFailure =
source.contains('add(') && _errorish.hasMatch(source);
if (emits || assignsState || rethrows || dispatchesFailure) continue;
swallowed++;
facts.add(
SwallowedFailure(
owner: owner,
member: member,
path: relativePath,
line: lineInfo.getLocation(catchClause.offset).lineNumber,
ordinal: swallowed,
handler: _oneLine(source),
),
);
}
}
}
static String _oneLine(String source) {
final collapsed = source.replaceAll(RegExp(r'\s+'), ' ').trim();
final body = collapsed.isEmpty ? '<empty catch>' : collapsed;
return body.length <= 120 ? body : '${body.substring(0, 117)}...';
}
}

153
lib/src/facts.dart Normal file
View file

@ -0,0 +1,153 @@
/// Language-neutral facts a front end extracts for the rules.
///
/// The rules judge these; they never read a syntax tree. A front end for
/// another language (a clang-based C++ extractor, say) produces the same facts
/// and every rule, baseline, verify and suggest path works unchanged. Each fact
/// round-trips through JSON so a front end can run as a separate process.
library;
/// A member puts its owner into an in-progress state, then awaits work whose
/// failure handler writes no state, rethrows, or dispatches a failure: the
/// busy state can outlive the failure (a spinner that never stops).
///
/// Variant style: the busy state is an emitted variant (`emit(Loading())`).
class SwallowedFailure {
SwallowedFailure({
required this.owner,
required this.member,
required this.path,
required this.line,
required this.ordinal,
required this.handler,
});
factory SwallowedFailure.fromJson(Map<String, Object?> json) =>
SwallowedFailure(
owner: json['owner']! as String,
member: json['member']! as String,
path: json['path']! as String,
line: json['line']! as int,
ordinal: json['ordinal']! as int,
handler: json['handler']! as String,
);
final String owner;
final String member;
/// Project-relative path and line of the failure handler (the catch).
final String path;
final int line;
/// 1-based position among the member's swallowed handlers: identity that
/// survives code moving, unlike the line.
final int ordinal;
/// The handler's source on one line, as evidence.
final String handler;
Map<String, Object?> toJson() => {
'owner': owner,
'member': member,
'path': path,
'line': line,
'ordinal': ordinal,
'handler': handler,
};
}
/// A member leaves a loading FLAG set across awaited work that no failure
/// handler resolves.
///
/// Flag style: the state is one class with a `bool loading`-like field, so
/// there is no variant to match on.
class UnsettledFlag {
UnsettledFlag({
required this.owner,
required this.member,
required this.path,
required this.line,
required this.stateType,
required this.fields,
required this.defaultsTrue,
required this.setsFlag,
required this.guardedTrySeen,
});
factory UnsettledFlag.fromJson(Map<String, Object?> json) => UnsettledFlag(
owner: json['owner']! as String,
member: json['member']! as String,
path: json['path']! as String,
line: json['line']! as int,
stateType: json['stateType']! as String,
fields: [...(json['fields']! as List).cast<String>()],
defaultsTrue: json['defaultsTrue']! as bool,
setsFlag: json['setsFlag']! as bool,
guardedTrySeen: json['guardedTrySeen']! as bool,
);
final String owner;
final String member;
final String path;
final int line;
final String stateType;
final List<String> fields;
final bool defaultsTrue;
/// The member itself sets the flag (as opposed to inheriting a default-true
/// flag by running from the constructor).
final bool setsFlag;
/// Some awaited work sat inside a try, though none of its catches resolved
/// the flag. Weaker evidence than no try at all.
final bool guardedTrySeen;
Map<String, Object?> toJson() => {
'owner': owner,
'member': member,
'path': path,
'line': line,
'stateType': stateType,
'fields': fields,
'defaultsTrue': defaultsTrue,
'setsFlag': setsFlag,
'guardedTrySeen': guardedTrySeen,
};
}
/// Files whose references did not resolve. Evidence in them can be missing,
/// so findings naming anything they mention are weaker.
class UnresolvedSummary {
UnresolvedSummary({
required this.files,
required this.analysed,
this.first,
this.names = const {},
});
factory UnresolvedSummary.fromJson(Map<String, Object?> json) =>
UnresolvedSummary(
files: [...(json['files']! as List).cast<String>()],
analysed: json['analysed']! as int,
first: json['first'] as String?,
names: {...(json['names']! as List).cast<String>()},
);
/// Project-relative paths.
final List<String> files;
final int analysed;
/// The first unresolved reference as `path:line: message`.
final String? first;
/// Every name those files mention.
final Set<String> names;
bool get any => files.isNotEmpty;
Map<String, Object?> toJson() => {
'files': files,
'analysed': analysed,
if (first != null) 'first': first,
'names': names.toList()..sort(),
};
}

View file

@ -1,9 +1,5 @@
import 'dart:convert';
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 'errors.dart';
import 'project.dart';
@ -360,16 +356,7 @@ FindingReport runChecks({
// Constructed type names per file, used to avoid reporting a handler as dead
// when its event is built somewhere and dispatched through a variable.
final constructedByFile = <String, Set<String>>{};
if (enabled.contains(ruleDeadHandler)) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
final collector = _ConstructionCollector();
unit.unit.accept(collector);
constructedByFile[project.relative(outcome.path)] = collector.constructed;
}
}
final constructedByFile = flow.constructedByFile;
String site(EmissionSite emission) =>
'${emission.path}:${emission.line}: ${emission.snippet}';
@ -734,16 +721,30 @@ FindingReport runChecks({
for (final emission in flow.emissions)
if (emission.kind == 'notifyListeners') emission.owner,
};
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_LoadingResetCollector(
project,
outcome.path,
unit.lineInfo,
stateOwners,
findings,
for (final fact in flow.swallowedFailures) {
if (!stateOwners.contains(fact.owner)) continue;
findings.add(
Finding(
rule: ruleLoadingNotReset,
severity: PipeSeverity.warning,
confidence: 'high',
message:
'${fact.owner}.${fact.member} emits an in-progress state and then '
'awaits an operation whose catch clause never emits a terminal '
'state, so the UI can be left showing a spinner forever.',
file: fact.path,
line: fact.line,
evidence: ['${fact.path}:${fact.line}: ${fact.handler}'],
remediation:
'Emit a failure/timeout state in the catch (or rethrow) so the '
'loading state is always resolved.',
data: {
'owner': fact.owner,
'member': fact.member,
// A method can hold more than one swallowed catch, so which one
// is part of this rule's identity.
'catch': fact.ordinal,
},
),
);
}
@ -754,23 +755,38 @@ FindingReport runChecks({
// on. An awaited call on the loading path that no catch resolves can leave the
// UI spinning forever, and this week's variant-based rule cannot see it.
if (enabled.contains(ruleLoadingFlagNotReset)) {
final flagStatesByOwner = <String, FlagState>{
for (final state in flow.flagStates) state.owner: state,
};
if (flagStatesByOwner.isNotEmpty) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_FlagLoadingCollector(
project,
outcome.path,
unit.lineInfo,
flagStatesByOwner,
findings,
),
);
}
for (final fact in flow.unsettledFlags) {
final fieldList = fact.fields.join(' / ');
findings.add(
Finding(
rule: ruleLoadingFlagNotReset,
severity: PipeSeverity.warning,
confidence: fact.setsFlag && !fact.guardedTrySeen ? 'high' : 'medium',
message:
'${fact.owner}.${fact.member} awaits work after putting '
'${fact.stateType}`$fieldList` into its loading state, and no '
'catch clause surfaces a failure, so the UI can keep showing its '
'loading state after an error.',
file: fact.path,
line: fact.line,
evidence: [
'${fact.path}:${fact.line}: ${fact.owner}.${fact.member} awaits '
'with ${fact.stateType}.$fieldList in flight and no catch that '
'emits a failure state',
],
remediation:
'Wrap the awaited call in try/catch and emit a state with '
'`$fieldList` reset (or rethrow), so the loading state always '
'resolves.',
data: {
'owner': fact.owner,
'member': fact.member,
'stateType': fact.stateType,
'fields': fact.fields,
'defaultsTrue': fact.defaultsTrue,
},
),
);
}
}
@ -846,7 +862,7 @@ FindingReport runChecks({
// evidence is name-based, so only a finding whose names an unresolved file
// mentions can have lost evidence there; lowering every finding for one
// stray error elsewhere would bury the signal.
final unresolved = findUnresolvedReferences(outcomes);
final unresolved = flow.unresolved;
if (unresolved.any) {
var lowered = 0;
for (var i = 0; i < findings.length; i++) {
@ -854,11 +870,10 @@ FindingReport runChecks({
findings[i] = findings[i].lowered('unresolved-references');
lowered++;
}
final first = unresolved.first!;
notes.add(
'${unresolved.files.length} of ${unresolved.analysed} analysed file(s) '
'have unresolved references (first: '
'${first.replaceFirst('${project.root}/', '')}). An unresolved `Foo()` '
'have unresolved references (first: ${unresolved.first}). An '
'unresolved `Foo()` '
'is not seen as a construction, so evidence in those files can be '
'missed: $lowered finding(s) naming something they mention had their '
'confidence lowered one step. Run `pipe index` and resolve the project '
@ -1020,53 +1035,6 @@ Set<String> decodeBaseline(String content) {
};
}
class _ConstructionCollector extends RecursiveAstVisitor<void> {
final Set<String> constructed = {};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
constructed.add(node.constructorName.type.name.lexeme);
super.visitInstanceCreationExpression(node);
}
}
class _TryCollector extends RecursiveAstVisitor<void> {
final List<TryStatement> tries = [];
@override
void visitTryStatement(TryStatement node) {
tries.add(node);
super.visitTryStatement(node);
}
// Nested functions are analysed on their own.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
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);
}
}
/// The names a finding makes a claim about: the values of its identity keys.
Set<String> _identityNames(Finding finding) {
const keys = [
@ -1091,395 +1059,3 @@ Set<String> _identityNames(Finding finding) {
}
return names;
}
/// `on<E>` for a bloc handler closure, so two handlers in one class get
/// distinct identities. As a bare `<closure>` they shared a fingerprint, and
/// the baseline dropped all but one of them.
String _closureMemberName(FunctionExpression node) {
final list = node.parent;
final call = list?.parent;
if (list is ArgumentList && call is MethodInvocation) {
final types = call.typeArguments?.arguments;
if (call.methodName.name == 'on' && types != null && types.length == 1) {
return 'on<${types.single.toSource()}>';
}
}
return '<closure>';
}
/// Finds awaited work on a loading-flag path that no catch clause resolves.
class _FlagLoadingCollector extends RecursiveAstVisitor<void> {
_FlagLoadingCollector(
this.project,
this.path,
this.lineInfo,
this.flagStatesByOwner,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final Map<String, FlagState> flagStatesByOwner;
final List<Finding> findings;
final List<String> _classStack = [];
/// Methods invoked from a constructor body: for those, a default-true loading
/// flag is in effect even though the method never sets it itself.
final Map<String, Set<String>> _constructorCalls = {};
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
static bool _surfacesFailure(String source) {
if (source.contains('emit(') || source.contains('notifyListeners(')) {
return true;
}
if (_stateAssign.hasMatch(source)) return true;
if (_throwing.hasMatch(source)) return true;
return source.contains('add(') && _errorish.hasMatch(source);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
final name = node.namePart.typeName.lexeme;
final calls = <String>{};
for (final member in node.body.members) {
if (member is! ConstructorDeclaration) continue;
final collector = _CalledNames();
member.body.accept(collector);
calls.addAll(collector.names);
}
if (calls.isNotEmpty) _constructorCalls[name] = calls;
_classStack.add(name);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final flag = flagStatesByOwner[owner];
if (flag == null) return;
// Lifecycle teardown never renders a loading state.
if (member == 'close' || member == 'dispose') return;
// Only methods that actually put the loading flag into the true state can
// leave the UI spinning. A method that merely runs in a class whose state
// has such a flag (a repository write, a teardown call) cannot.
//
// Every probe here stops at nested functions, which are analysed on their
// own. The flag probe and the await probe used to read closure bodies while
// the try collector skipped them, so a method whose closure set the flag
// and guarded its own await was reported at high confidence.
final flagProbe = _SetsFlagTrue(flag.fields.toSet());
body.accept(flagProbe);
final setsFlagTrue = flagProbe.found;
final inheritsInitialFlag =
flag.defaultsTrue &&
(_constructorCalls[owner]?.contains(member) ?? false);
if (!setsFlagTrue && !inheritsInitialFlag) return;
// Guarded when an awaited try has a catch that surfaces the failure.
final tries = _TryCollector();
body.accept(tries);
var awaitedTryGuarded = false;
var awaitedTrySeen = false;
for (final statement in tries.tries) {
final awaitedInTry = _HasAwait(shallow: true);
statement.body.accept(awaitedInTry);
if (!awaitedInTry.found) continue;
awaitedTrySeen = true;
for (final catchClause in statement.catchClauses) {
if (_surfacesFailure(catchClause.body.toSource())) {
awaitedTryGuarded = true;
}
}
}
if (awaitedTryGuarded) return;
// Nothing awaited at all means there is no failure path to leave dangling.
final awaitProbe = _HasAwait(shallow: true);
body.accept(awaitProbe);
if (!awaitProbe.found) return;
final fieldList = flag.fields.join(' / ');
findings.add(
Finding(
rule: ruleLoadingFlagNotReset,
severity: PipeSeverity.warning,
confidence: setsFlagTrue && !awaitedTrySeen ? 'high' : 'medium',
message:
'$owner.$member awaits work after putting ${flag.name}`$fieldList` '
'into its loading state, and no catch clause surfaces a failure, so '
'the UI can keep showing its loading state after an error.',
file: project.relative(path),
line: lineInfo.getLocation(body.offset).lineNumber,
evidence: [
'${project.relative(path)}:'
'${lineInfo.getLocation(body.offset).lineNumber}: '
'$owner.$member awaits with ${flag.name}.$fieldList in flight and '
'no catch that emits a failure state',
],
remediation:
'Wrap the awaited call in try/catch and emit a state with '
'`$fieldList` reset (or rethrow), so the loading state always '
'resolves.',
data: {
'owner': owner,
'member': member,
'stateType': flag.name,
'fields': flag.fields,
'defaultsTrue': flag.defaultsTrue,
},
),
);
}
}
/// Scans a single function body for in-flight loading emits and top-level
/// awaited try statements.
class _BodyScan extends RecursiveAstVisitor<void> {
final List<int> loadingEmits = [];
final List<TryStatement> topLevelTries = [];
static final RegExp _loading = RegExp(
r'(Loading|Processing|Submitting|Saving|Creating|Updating|InProgress)',
);
int _tryDepth = 0;
void _maybeLoading(AstNode node, List<Argument> arguments) {
if (arguments.isEmpty) return;
final first = arguments.first;
if (first is! Expression) return;
if (_loading.hasMatch(first.toSource())) loadingEmits.add(node.offset);
}
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier && function.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitFunctionExpressionInvocation(node);
}
@override
void visitTryStatement(TryStatement node) {
final isTopLevel = _tryDepth == 0;
_tryDepth++;
if (isTopLevel) topLevelTries.add(node);
super.visitTryStatement(node);
_tryDepth--;
}
// Nested functions are analysed in their own right by _analyze; descending
// into them here would report the same catch twice.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _HasAwait extends RecursiveAstVisitor<void> {
_HasAwait({this.shallow = false});
/// Stop at nested functions: an await inside a closure is not awaited by the
/// body that declares the closure.
final bool shallow;
bool found = false;
@override
void visitAwaitExpression(AwaitExpression node) {
found = true;
super.visitAwaitExpression(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
if (!shallow) super.visitFunctionExpression(node);
}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
if (!shallow) super.visitFunctionDeclaration(node);
}
}
/// A `field: true` named argument for one of the loading fields, outside any
/// nested function.
class _SetsFlagTrue extends RecursiveAstVisitor<void> {
_SetsFlagTrue(this.fields);
final Set<String> fields;
bool found = false;
@override
void visitNamedArgument(NamedArgument node) {
final value = node.argumentExpression;
if (fields.contains(node.name.lexeme) &&
value is BooleanLiteral &&
value.value) {
found = true;
}
super.visitNamedArgument(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _LoadingResetCollector extends RecursiveAstVisitor<void> {
_LoadingResetCollector(
this.project,
this.path,
this.lineInfo,
this.stateOwners,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final Set<String> stateOwners;
final List<Finding> findings;
final List<String> _classStack = [];
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
if (!stateOwners.contains(owner)) return;
final scan = _BodyScan();
body.accept(scan);
if (scan.loadingEmits.isEmpty) return;
// Position within the member, not the line: two swallowed catches in one
// method need distinct fingerprints, and a line number changed whenever
// anything above the method moved.
var swallowed = 0;
for (final statement in scan.topLevelTries) {
if (statement.catchClauses.isEmpty) continue;
final probe = _HasAwait();
statement.body.accept(probe);
if (!probe.found) continue;
for (final catchClause in statement.catchClauses) {
final source = catchClause.body.toSource();
final emits =
source.contains('emit(') || source.contains('notifyListeners(');
final assignsState = _stateAssign.hasMatch(source);
final rethrows = _throwing.hasMatch(source);
final dispatchesFailure =
source.contains('add(') && _errorish.hasMatch(source);
if (emits || assignsState || rethrows || dispatchesFailure) continue;
swallowed++;
findings.add(
Finding(
rule: ruleLoadingNotReset,
severity: PipeSeverity.warning,
confidence: 'high',
message:
'$owner.$member emits an in-progress state and then awaits an '
'operation whose catch clause never emits a terminal state, so '
'the UI can be left showing a spinner forever.',
file: project.relative(path),
line: lineInfo.getLocation(catchClause.offset).lineNumber,
evidence: [
'${project.relative(path)}:'
'${lineInfo.getLocation(catchClause.offset).lineNumber}: '
'${_oneLine(source)}',
],
remediation:
'Emit a failure/timeout state in the catch (or rethrow) so the '
'loading state is always resolved.',
data: {
'owner': owner,
'member': member,
// A method can hold more than one swallowed catch, so which one
// is part of this rule's identity.
'catch': swallowed,
},
),
);
}
}
}
static String _oneLine(String source) {
final collapsed = source.replaceAll(RegExp(r'\s+'), ' ').trim();
final body = collapsed.isEmpty ? '<empty catch>' : collapsed;
return body.length <= 120 ? body : '${body.substring(0, 117)}...';
}
}

View file

@ -4,6 +4,8 @@ import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'analysis.dart';
import 'dart_facts.dart';
import 'facts.dart';
import 'project.dart';
/// How a state union is expressed in source.
@ -354,7 +356,12 @@ class StateFlow {
required this.ownerReferences,
required this.flagStates,
required this.riverpod,
});
this.constructedByFile = const {},
this.swallowedFailures = const [],
this.unsettledFlags = const [],
UnresolvedSummary? unresolved,
}) : unresolved =
unresolved ?? UnresolvedSummary(files: const [], analysed: 0);
final String root;
final List<StateUnion> unions;
@ -376,6 +383,19 @@ class StateFlow {
/// Riverpod providers and their consumption sites.
final RiverpodModel riverpod;
/// Project-relative file -> type names constructed in it.
final Map<String, Set<String>> constructedByFile;
/// Busy variant emitted, then an awaited failure swallowed (loading-not-reset).
final List<SwallowedFailure> swallowedFailures;
/// Loading flag left set across unguarded awaited work
/// (loading-flag-not-reset).
final List<UnsettledFlag> unsettledFlags;
/// Files whose references did not resolve, and the names they mention.
final UnresolvedSummary unresolved;
Map<String, Object?> toJson() => {
'root': root,
'totals': {
@ -400,6 +420,13 @@ class StateFlow {
'ownerReferences': ownerReferences,
'flagStates': [for (final state in flagStates) state.toJson()],
'riverpod': riverpod.toJson(),
'constructedByFile': {
for (final entry in constructedByFile.entries)
entry.key: entry.value.toList()..sort(),
},
'swallowedFailures': [for (final fact in swallowedFailures) fact.toJson()],
'unsettledFlags': [for (final fact in unsettledFlags) fact.toJson()],
'unresolved': unresolved.toJson(),
};
/// Human summary, ordered by the most suspicious signal first.
@ -1788,10 +1815,33 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
final dispatches = <EventDispatch>[];
final riverpodProviders = <RiverpodProvider>[];
final riverpodSites = <RiverpodSite>[];
final constructedByFile = <String, Set<String>>{};
final swallowedFailures = <SwallowedFailure>[];
final unsettledFlags = <UnsettledFlag>[];
final flagStatesByOwner = <String, FlagState>{
for (final state in flagStates) state.owner: state,
};
for (final entry in unitsByPath.entries) {
final result = entry.value;
final unit = result.unit;
final lineInfo = result.lineInfo;
final relative = project.relative(entry.key);
final constructions = ConstructionCollector();
unit.accept(constructions);
constructedByFile[relative] = constructions.constructed;
unit.accept(
SwallowedFailureCollector(relative, lineInfo, swallowedFailures),
);
if (flagStatesByOwner.isNotEmpty) {
unit.accept(
UnsettledFlagCollector(
relative,
lineInfo,
flagStatesByOwner,
unsettledFlags,
),
);
}
unit.accept(
_EmissionCollector(project, entry.key, lineInfo, emissions, redirects),
);
@ -1886,5 +1936,23 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
ownerReferences: ownerReferences,
flagStates: flagStates,
riverpod: RiverpodModel(providers: riverpodProviders, sites: riverpodSites),
constructedByFile: constructedByFile,
swallowedFailures: swallowedFailures,
unsettledFlags: unsettledFlags,
unresolved: _unresolvedSummary(project, outcomes),
);
}
UnresolvedSummary _unresolvedSummary(
Project project,
List<FileOutcome> outcomes,
) {
final found = findUnresolvedReferences(outcomes);
final first = found.first;
return UnresolvedSummary(
files: [for (final file in found.files) project.relative(file)],
analysed: found.analysed,
first: first?.replaceFirst('${project.root}/', ''),
names: found.names,
);
}