pipe/test/rule_contract_test.dart
trvon 939cdcb251
fix(riverpod): recognise AsyncError patterns and follow stored values
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.
2026-09-22 13:45:11 -06:00

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