riverpod-async-gap judged handling from the text of the expression a `ref.watch` flows into, which missed two common shapes: - Pattern handling. A switch with `AsyncError()` / `AsyncLoading()` cases - the form pipe's own remediation recommends - did not count as handling, and a switch STATEMENT on the watched value was never looked at, because the parent climb stops at the first non-expression. A provider handled that way in one widget and read with `.value` in another was reported as unhandled. - A value stored in a local. `final v = ref.watch(p); ... v.when(...)` stopped the climb at the declaration, so both the handling and the read were invisible: a handled provider could be reported, and an unhandled one read as `v.value` was missed (a recall gap, not only precision). The consumer now widens to an enclosing switch statement, the uses of a declared local are followed through the enclosing body, and AsyncError/AsyncLoading patterns count as handling. Fixture: patterned, switched and stored providers (each also read directly elsewhere, so the controls are not vacuous) were all reported before and are silent now; storedUnhandledProvider was missed and is now reported. Real repos: tick's categoriesStreamProvider is no longer reported; its categories screen handles it with `catsAsync.when(...)` through a variable, which the rule's any-site-handles definition counts as handled. nuwayze and dex unchanged. word_alive's internalUserProvider finding comes from a commit made to word_alive during this work, not from this change: the old code reports it too.
333 lines
13 KiB
Dart
333 lines
13 KiB
Dart
import 'package:pipe/pipe.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
const String fixturePath = 'test/fixtures/stuck_loading';
|
|
|
|
/// A rule's positive control: it MUST fire on this identity.
|
|
typedef _Id = (String key, String value);
|
|
|
|
/// Every rule must have both a positive control (recall) and a negative control
|
|
/// (precision) drawn from an adjacent fixture case. A rule added without them
|
|
/// fails the completeness test at the bottom.
|
|
const Map<String, _Id> _positives = {
|
|
ruleEmittedNotRendered: ('union', 'ItemState'),
|
|
ruleRenderedNotEmitted: ('union', 'GapState'),
|
|
ruleLoadingNotReset: ('owner', 'ItemBloc'),
|
|
ruleLoadingFlagNotReset: ('member', 'loadUnguarded'),
|
|
ruleEventWithoutHandler: ('event', 'UnhandledTap'),
|
|
ruleDeadHandler: ('event', 'NeverDispatched'),
|
|
ruleRiverpodAsyncGap: ('provider', 'unhandledProvider'),
|
|
ruleNotifierNeverNotifies: ('member', 'bumpWithoutNotifying'),
|
|
ruleNotifierInert: ('owner', 'InertNotifier'),
|
|
ruleProviderReadInBuild: ('valueType', 'LiveNotifier'),
|
|
ruleNotifierFieldUnread: ('owner', 'PartiallyReadNotifier'),
|
|
ruleProviderCreatedInBuild: ('valueTypes', 'LiveNotifier'),
|
|
};
|
|
|
|
const Map<String, List<_Id>> _negatives = {
|
|
// PatternA/PatternB are emitted AND matched (via typed variable patterns), so
|
|
// they are the real precision control here. Asserting on a variant that is
|
|
// never emitted would prove nothing.
|
|
ruleEmittedNotRendered: [('variants', 'PatternA'), ('variants', 'PatternB')],
|
|
// PatternState HAS render sites and every variant it renders is emitted.
|
|
// ItemState would be vacuous: it has no render sites, so it could never be
|
|
// reported regardless of the emission logic.
|
|
// DocLoading/DocReady are emitted through freezed-style redirecting factories
|
|
// (`DocState.loading()`), which must be credited to the redirect target.
|
|
ruleRenderedNotEmitted: [
|
|
('union', 'PatternState'),
|
|
('variant', 'DocLoading'),
|
|
('variant', 'DocReady'),
|
|
],
|
|
// GuardedCubit emits a loading variant and awaits inside a try whose catch
|
|
// resolves it, so reporting it would be a false positive. FlagCubit would be
|
|
// vacuous: it has no variant union and is skipped outright.
|
|
ruleLoadingNotReset: [('owner', 'GuardedCubit')],
|
|
// loadGuarded resolves the flag, refreshQuietly never sets it, and `_warmUp`
|
|
// is constructor-invoked on a flag defaulting to FALSE, so nothing is in
|
|
// flight.
|
|
// scheduleGuarded's closure sets the flag and guards its own await, and
|
|
// registerHandlers only registers closures: neither enclosing method is in
|
|
// flight itself. Every closure gets a real name, never `<closure>`.
|
|
ruleLoadingFlagNotReset: [
|
|
('member', 'loadGuarded'),
|
|
('member', 'refreshQuietly'),
|
|
('member', '_warmUp'),
|
|
('member', 'scheduleGuarded'),
|
|
('member', 'registerHandlers'),
|
|
('member', '<closure>'),
|
|
],
|
|
// HandledTap is handled AND dispatched, so it is the control that proves both
|
|
// event rules actually consult the handler and dispatch information.
|
|
// NeverDispatched (never handled) and UnhandledTap (never handled) were
|
|
// vacuous.
|
|
// SyncStarted/UploadRetryNow are handled through `on<SyncEvent>` and
|
|
// `on<UploadRetry>`; those base and group handlers are live because a
|
|
// subtype is dispatched (base_event.dart).
|
|
ruleEventWithoutHandler: [
|
|
('event', 'HandledTap'),
|
|
('event', 'SyncStarted'),
|
|
('event', 'UploadRetryNow'),
|
|
// Handled by ShareBetaBloc, which shares ShareEvent with ShareAlphaBloc.
|
|
('event', 'ShareDismissed'),
|
|
],
|
|
ruleDeadHandler: [
|
|
('event', 'HandledTap'),
|
|
('event', 'SyncEvent'),
|
|
('event', 'UploadRetry'),
|
|
],
|
|
// handledProvider covers all AsyncValue cases.
|
|
// The others are handled with AsyncError/AsyncLoading patterns (switch
|
|
// expression and statement) or through a stored variable, and each is also
|
|
// read directly elsewhere, so a gap would be reported if the handling were
|
|
// missed.
|
|
ruleRiverpodAsyncGap: [
|
|
('provider', 'handledProvider'),
|
|
('provider', 'patternedProvider'),
|
|
('provider', 'switchedProvider'),
|
|
('provider', 'storedProvider'),
|
|
],
|
|
// `bump` notifies; `setQuiet` mutates but a notifying method calls it.
|
|
ruleNotifierNeverNotifies: [('member', 'bump'), ('member', 'setQuiet')],
|
|
// LiveNotifier notifies, so its Consumer can rebuild.
|
|
ruleNotifierInert: [('owner', 'LiveNotifier')],
|
|
// Reading an immutable service in build is correct; a rule that flags every
|
|
// context.read would fire here and is wrong.
|
|
ruleProviderReadInBuild: [('valueType', 'AppConfig')],
|
|
// `value` IS displayed by the UI, so it must never be listed as unread.
|
|
ruleNotifierFieldUnread: [('members', 'value')],
|
|
// A provider for AppConfig is constructed in build via `.value` and one is
|
|
// declared at top level; neither may be reported.
|
|
ruleProviderCreatedInBuild: [('valueTypes', 'AppConfig')],
|
|
};
|
|
|
|
void main() {
|
|
late ProjectAnalysis analysis;
|
|
late List<Finding> findings;
|
|
|
|
setUpAll(() async {
|
|
final project = await loadProject(fixturePath);
|
|
analysis = await analyzeProject(project, concurrency: 1);
|
|
findings = analysis.report.findings;
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
await analysis.dispose();
|
|
});
|
|
|
|
Iterable<String> valuesOf(String rule, String key) sync* {
|
|
for (final finding in findings.where((f) => f.rule == rule)) {
|
|
final value = finding.data[key];
|
|
if (value is String) {
|
|
yield value;
|
|
} else if (value is List) {
|
|
// Grouped findings carry a list (e.g. `variants`), so a negative
|
|
// control on a grouped rule must inspect membership.
|
|
for (final entry in value) {
|
|
if (entry is String) yield entry;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test('dead-handler: every bloc sharing an event type is checked', () {
|
|
// Only the first owner of ShareEvent used to be kept, so ShareBetaBloc's
|
|
// handlers were never examined.
|
|
final owners = {
|
|
for (final finding in findings)
|
|
if (finding.rule == ruleDeadHandler &&
|
|
finding.data['event'] == 'ShareExpired')
|
|
finding.data['owner'],
|
|
};
|
|
expect(owners, {'ShareBetaBloc'});
|
|
});
|
|
|
|
test('rendered-not-emitted: redirect resolution does not mute the rule', () {
|
|
// DocStale is rendered and never emitted, so crediting factory calls to
|
|
// their redirect targets must leave it reported.
|
|
expect(valuesOf(ruleRenderedNotEmitted, 'variant'), contains('DocStale'));
|
|
});
|
|
|
|
test(
|
|
'riverpod-async-gap: a value stored in a variable is still followed',
|
|
() {
|
|
// The parent climb stopped at the variable declaration, so a stored value
|
|
// read with `.value` was invisible to the rule.
|
|
expect(
|
|
valuesOf(ruleRiverpodAsyncGap, 'provider'),
|
|
contains('storedUnhandledProvider'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('event-without-handler: a group handler does not cover a sibling', () {
|
|
// UploadAbandoned sits outside the handled UploadRetry group, so following
|
|
// the hierarchy must not over-credit it.
|
|
expect(
|
|
valuesOf(ruleEventWithoutHandler, 'event'),
|
|
contains('UploadAbandoned'),
|
|
);
|
|
});
|
|
|
|
test('loading-flag-not-reset: two handlers in one class stay distinct', () {
|
|
// As `<closure>` they shared one fingerprint, so a baseline kept only one.
|
|
final hits = findings
|
|
.where(
|
|
(finding) =>
|
|
finding.rule == ruleLoadingFlagNotReset &&
|
|
finding.data['owner'] == 'FlagCubit',
|
|
)
|
|
.toList();
|
|
expect(
|
|
hits.map((finding) => finding.data['member']),
|
|
containsAll(<String>['on<FlagRefresh>', 'on<FlagReload>']),
|
|
);
|
|
expect(
|
|
hits.map((finding) => finding.fingerprint).toSet(),
|
|
hasLength(hits.length),
|
|
);
|
|
});
|
|
|
|
for (final entry in _positives.entries) {
|
|
test('${entry.key}: fires on the positive control', () {
|
|
final (key, value) = entry.value;
|
|
expect(
|
|
valuesOf(entry.key, key),
|
|
contains(value),
|
|
reason: 'rule ${entry.key} did not fire for $key=$value',
|
|
);
|
|
});
|
|
}
|
|
|
|
for (final entry in _negatives.entries) {
|
|
test('${entry.key}: stays silent on the negative control', () {
|
|
for (final (key, value) in entry.value) {
|
|
expect(
|
|
valuesOf(entry.key, key),
|
|
isNot(contains(value)),
|
|
reason: 'rule ${entry.key} wrongly fired for $key=$value',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('every rule has a positive and a negative control', () {
|
|
for (final rule in allRules) {
|
|
expect(
|
|
_positives.containsKey(rule),
|
|
isTrue,
|
|
reason: '$rule has no positive control (recall is unproven)',
|
|
);
|
|
expect(
|
|
_negatives.containsKey(rule),
|
|
isTrue,
|
|
reason: '$rule has no negative control (precision is unguarded)',
|
|
);
|
|
}
|
|
// And the controls must not reference rules that no longer exist.
|
|
for (final rule in _positives.keys) {
|
|
expect(allRules, contains(rule));
|
|
}
|
|
});
|
|
|
|
test('a union no UI observes collapses to ONE decision, not N bugs', () {
|
|
// gaps_ui.dart renders GapState but never ItemState, so every emitted
|
|
// ItemState variant is unreachable. That is one product decision, and
|
|
// reporting three separate findings for it buries the real regressions.
|
|
final hits = findings
|
|
.where(
|
|
(finding) =>
|
|
finding.rule == ruleEmittedNotRendered &&
|
|
finding.data['union'] == 'ItemState',
|
|
)
|
|
.toList();
|
|
|
|
expect(hits, hasLength(1));
|
|
final only = hits.single;
|
|
expect(only.data['dormant'], isTrue);
|
|
expect(only.data['variantCount'], greaterThan(1));
|
|
expect(only.data['variants'], isA<List<String>>());
|
|
expect(
|
|
only.data.containsKey('variant'),
|
|
isFalse,
|
|
reason: 'a dormant finding keys on the union, not on one variant',
|
|
);
|
|
expect(only.confidence, 'high');
|
|
// The default policy leads with wiring the feature up.
|
|
expect(only.remediation, startsWith('Wire it up'));
|
|
});
|
|
|
|
test('read-in-build fires only for Listenables inside a build method', () {
|
|
final hits = findings
|
|
.where((finding) => finding.rule == ruleProviderReadInBuild)
|
|
.toList();
|
|
|
|
// Exactly two: context.read and Provider.of(listen: false), both in build.
|
|
// context.watch in build subscribes, reading an immutable service is fine,
|
|
// and a read in a callback is correct, so none of those may be reported.
|
|
expect(hits.map((f) => f.data['call']).toList()..sort(), [
|
|
'Provider.of<LiveNotifier>(context, listen: false)',
|
|
'context.read<LiveNotifier>()',
|
|
]);
|
|
expect(hits.map((f) => f.data['valueType']), everyElement('LiveNotifier'));
|
|
expect(hits.first.confidence, 'medium');
|
|
expect(hits.first.remediation, contains('context.watch'));
|
|
});
|
|
|
|
test('field-unread lists only members nothing reads', () {
|
|
final hits = findings
|
|
.where((finding) => finding.rule == ruleNotifierFieldUnread)
|
|
.toList();
|
|
|
|
expect(hits, hasLength(1));
|
|
expect(hits.single.data['owner'], 'PartiallyReadNotifier');
|
|
expect(hits.single.data['members'], ['unusedFlag']);
|
|
// Reported once per notifier, not once per unread member.
|
|
expect(hits.single.data['memberCount'], 1);
|
|
expect(hits.single.confidence, 'medium');
|
|
});
|
|
|
|
test('created-in-build fires once per build method, ignoring `.value`', () {
|
|
final hits = findings
|
|
.where((f) => f.rule == ruleProviderCreatedInBuild)
|
|
.toList();
|
|
|
|
// Only CreatesProviderView counts, and it is reported once for the build
|
|
// method rather than once per provider. The two `.value` cases pass an
|
|
// instance created once, so they are correct.
|
|
expect(hits, hasLength(1));
|
|
expect(hits.single.data['owner'], 'CreatesProviderView');
|
|
expect(hits.single.data['member'], 'build');
|
|
expect(hits.single.data['providerCount'], 1);
|
|
expect(hits.single.data['firstKind'], 'ChangeNotifierProvider');
|
|
expect(hits.single.data['valueTypes'], ['LiveNotifier']);
|
|
expect(hits.single.remediation, contains('.value'));
|
|
});
|
|
|
|
test('a variant name shared with another union is not cross-credited', () {
|
|
// The fixture declares DuplicatedName twice, in two files with different
|
|
// unions, and only AlphaState's is rendered. Unqualified-name matching
|
|
// without per-union scoping would mark the Beta variant rendered too and
|
|
// silently drop this finding.
|
|
final hits = findings
|
|
.where(
|
|
(finding) =>
|
|
finding.rule == ruleEmittedNotRendered &&
|
|
(finding.data['variants'] as List?)?.contains('DuplicatedName') ==
|
|
true,
|
|
)
|
|
.toList();
|
|
expect(hits, hasLength(1));
|
|
expect(hits.single.data['union'], 'BetaState');
|
|
});
|
|
|
|
test('every reported rule in the contract corpus is covered', () {
|
|
final reported = findings.map((finding) => finding.rule).toSet();
|
|
final uncovered = reported.difference(_positives.keys.toSet());
|
|
expect(
|
|
uncovered,
|
|
isEmpty,
|
|
reason: 'these rules fired without a contract: $uncovered',
|
|
);
|
|
});
|
|
}
|