Found by dogfooding on nuwayze main: `pipe verify` against the ratchet
baseline printed
resolved 1 no longer reported - these fixes are confirmed
+ service_browse_bloc.dart:20 LoadServicesByArtist [dead-handler]
but nothing was fixed. nuwayze commit ccca9bd1e ("delete dead firestore
query code") deleted the event and its handler. Under pipe's default
wire-up policy, deleting an unreachable handler is the exception that
needs approval, so calling it a confirmed fix overstated what happened.
verify now separates the two. A gone finding is REMOVED when its
subject no longer exists:
- its file is not among the project's files;
- for rules labelled by a declaration (the event/state/union/provider or
owner class), that name is no longer declared anywhere;
- for dead-handler, the on<Event> registration itself is gone.
Everything else stays RESOLVED. Member-labelled rules (a method or
build) are classed by file only, never guessed. Removed entries still
count as gone for --expect-resolved; they are only named honestly, in
text ("removed N gone because the code was deleted - not wired up",
a REMOVED section) and JSON (removed, removedEntries).
On nuwayze main the entry now reads REMOVED. Tests: a unit test over
hand-built entries (a wired handler stays resolved, a deleted handler
and a deleted file are removed, a member label is not guessed), and an
end-to-end test that deletes a fixture's dead handler and runs
verifyAnalysis. 216 tests pass.
277 lines
8.8 KiB
Dart
277 lines
8.8 KiB
Dart
import 'package:pipe/pipe.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
Finding _finding({
|
|
required String rule,
|
|
String? file,
|
|
int? line,
|
|
Map<String, Object?> data = const {},
|
|
}) => Finding(
|
|
rule: rule,
|
|
severity: PipeSeverity.warning,
|
|
confidence: 'high',
|
|
message: 'message',
|
|
file: file,
|
|
line: line,
|
|
data: data,
|
|
);
|
|
|
|
void main() {
|
|
final present = _finding(
|
|
rule: 'emitted-not-rendered',
|
|
file: 'lib/a.dart',
|
|
line: 10,
|
|
data: const {'variant': 'StillBroken'},
|
|
);
|
|
final fixed = _finding(
|
|
rule: 'loading-flag-not-reset',
|
|
file: 'lib/b.dart',
|
|
line: 20,
|
|
data: const {'member': 'loadNow'},
|
|
);
|
|
|
|
test('baseline entries round-trip with a readable label', () {
|
|
final encoded = encodeBaseline('/root', [present, fixed]);
|
|
final entries = decodeBaselineEntries(encoded);
|
|
|
|
expect(entries, hasLength(2));
|
|
final byLabel = {for (final entry in entries) entry.label: entry};
|
|
expect(byLabel.keys, containsAll(<String>['StillBroken', 'loadNow']));
|
|
expect(byLabel['StillBroken']!.rule, 'emitted-not-rendered');
|
|
expect(byLabel['StillBroken']!.location, 'lib/a.dart:10');
|
|
// The bare fingerprint list must keep working for `check --baseline`.
|
|
expect(decodeBaseline(encoded), hasLength(2));
|
|
});
|
|
|
|
test('a legacy fingerprints-only baseline still decodes', () {
|
|
const legacy = '{"version":1,"fingerprints":["r|a.dart|variant=V"]}';
|
|
expect(decodeBaseline(legacy), {'r|a.dart|variant=V'});
|
|
final entries = decodeBaselineEntries(legacy);
|
|
expect(entries.single.label, 'r|a.dart|variant=V');
|
|
expect(entries.single.rule, 'r');
|
|
});
|
|
|
|
test('verify separates resolved from still-present', () {
|
|
final report = FindingReport(
|
|
root: '/root',
|
|
findings: [present],
|
|
suppressed: const [],
|
|
);
|
|
final baseline = [
|
|
BaselineEntry(
|
|
fingerprint: present.fingerprint,
|
|
rule: present.rule,
|
|
file: present.file!,
|
|
line: present.line,
|
|
label: 'StillBroken',
|
|
),
|
|
BaselineEntry(
|
|
fingerprint: fixed.fingerprint,
|
|
rule: fixed.rule,
|
|
file: fixed.file!,
|
|
line: fixed.line,
|
|
label: 'loadNow',
|
|
),
|
|
];
|
|
|
|
final verification = verifyAgainstBaseline(
|
|
report: report,
|
|
baseline: baseline,
|
|
);
|
|
|
|
expect(verification.baselineTotal, 2);
|
|
expect(verification.resolved.map((e) => e.label), ['loadNow']);
|
|
expect(verification.stillPresent.map((e) => e.label), ['StillBroken']);
|
|
expect(verification.newFindings, isEmpty);
|
|
expect(verification.allResolved, isFalse);
|
|
expect(verification.toJson(), containsPair('resolved', 1));
|
|
expect(verification.describe(), contains('these fixes are confirmed'));
|
|
expect(verification.describe(), contains('loadNow'));
|
|
});
|
|
|
|
test('allResolved is true only when nothing accepted remains', () {
|
|
final report = FindingReport(
|
|
root: '/root',
|
|
findings: const [],
|
|
suppressed: const [],
|
|
);
|
|
final verification = verifyAgainstBaseline(
|
|
report: report,
|
|
baseline: [
|
|
BaselineEntry(
|
|
fingerprint: fixed.fingerprint,
|
|
rule: fixed.rule,
|
|
file: fixed.file!,
|
|
line: fixed.line,
|
|
label: 'loadNow',
|
|
),
|
|
],
|
|
);
|
|
expect(verification.allResolved, isTrue);
|
|
expect(verification.resolved, hasLength(1));
|
|
});
|
|
|
|
test('a finding that is new since the baseline is reported as new', () {
|
|
final fresh = _finding(
|
|
rule: 'dead-handler',
|
|
file: 'lib/c.dart',
|
|
line: 1,
|
|
data: const {'event': 'NewlyDead'},
|
|
);
|
|
final report = FindingReport(
|
|
root: '/root',
|
|
findings: [fresh],
|
|
suppressed: const [],
|
|
);
|
|
final verification = verifyAgainstBaseline(
|
|
report: report,
|
|
baseline: const [],
|
|
);
|
|
expect(verification.newFindings.map((f) => f.baselineLabel), ['NewlyDead']);
|
|
expect(verification.resolved, isEmpty);
|
|
});
|
|
|
|
test('verify --rule judges only the baselined findings of that rule', () {
|
|
// `verify --rule X` compared against the WHOLE baseline, so every accepted
|
|
// finding of every other rule was absent from the fresh report - because
|
|
// those rules never ran - and was reported as RESOLVED.
|
|
final report = FindingReport(
|
|
root: '/root',
|
|
findings: [present],
|
|
suppressed: const [],
|
|
);
|
|
final verification = verifyAgainstBaseline(
|
|
report: report,
|
|
baseline: [
|
|
BaselineEntry(
|
|
fingerprint: present.fingerprint,
|
|
rule: present.rule,
|
|
file: present.file!,
|
|
line: present.line,
|
|
label: 'StillBroken',
|
|
),
|
|
BaselineEntry(
|
|
fingerprint: fixed.fingerprint,
|
|
rule: fixed.rule,
|
|
file: fixed.file!,
|
|
line: fixed.line,
|
|
label: 'loadNow',
|
|
),
|
|
// A fingerprints-only baseline carries no rule; it is the fingerprint's
|
|
// first segment.
|
|
BaselineEntry(
|
|
fingerprint: 'dead-handler|lib/c.dart|event=Old',
|
|
rule: '',
|
|
file: '',
|
|
label: 'dead-handler|lib/c.dart|event=Old',
|
|
),
|
|
],
|
|
rules: {'emitted-not-rendered'},
|
|
);
|
|
|
|
expect(verification.resolved, isEmpty);
|
|
expect(verification.stillPresent.map((e) => e.label), ['StillBroken']);
|
|
expect(verification.baselineTotal, 1);
|
|
expect(verification.outOfScope, 2);
|
|
expect(verification.describe(), isNot(contains('confirmed')));
|
|
expect(verification.describe(), contains('rules not run'));
|
|
});
|
|
|
|
test('a baselined finding in an unscanned file is not resolved', () {
|
|
// Absent from the report because nothing read its file - excluded by
|
|
// analysis_options, or outside the scanned --dir - is not the same as
|
|
// fixed, so it must not be counted as a confirmed fix.
|
|
final outsideDirs = _finding(
|
|
rule: 'dead-handler',
|
|
file: 'test/helpers.dart',
|
|
line: 3,
|
|
data: const {'event': 'OnlyInTests'},
|
|
);
|
|
final report = FindingReport(
|
|
root: '/root',
|
|
findings: const [],
|
|
suppressed: const [],
|
|
);
|
|
BaselineEntry entryOf(Finding finding, String label) => BaselineEntry(
|
|
fingerprint: finding.fingerprint,
|
|
rule: finding.rule,
|
|
file: finding.file!,
|
|
line: finding.line,
|
|
label: label,
|
|
);
|
|
final verification = verifyAgainstBaseline(
|
|
report: report,
|
|
baseline: [
|
|
entryOf(present, 'StillBroken'),
|
|
entryOf(fixed, 'loadNow'),
|
|
entryOf(outsideDirs, 'OnlyInTests'),
|
|
],
|
|
unscannedFiles: {'lib/b.dart'},
|
|
scannedDirectories: ['lib'],
|
|
);
|
|
|
|
expect(verification.resolved.map((e) => e.label), ['StillBroken']);
|
|
expect(
|
|
verification.notScanned.map((e) => e.label),
|
|
unorderedEquals(['loadNow', 'OnlyInTests']),
|
|
);
|
|
expect(verification.allResolved, isFalse);
|
|
expect(verification.toJson(), containsPair('notScanned', 2));
|
|
expect(verification.describe(), contains('NOT SCANNED'));
|
|
});
|
|
|
|
test('a finding whose subject was deleted is removed, not resolved', () {
|
|
// nuwayze deleted a dead handler's event ("delete dead firestore query
|
|
// code") and verify printed it under "these fixes are confirmed". Under the
|
|
// default wire-up policy a deletion is not the fix the finding asked for.
|
|
Finding dead(String event, String file) => _finding(
|
|
rule: 'dead-handler',
|
|
file: file,
|
|
line: 5,
|
|
data: {'union': 'E', 'owner': 'B', 'event': event},
|
|
);
|
|
final wired = dead('Wired', 'lib/bloc.dart');
|
|
final deletedHandler = dead('HandlerGone', 'lib/bloc.dart');
|
|
final deletedFile = _finding(
|
|
rule: 'loading-flag-not-reset',
|
|
file: 'lib/gone.dart',
|
|
line: 3,
|
|
data: const {'owner': 'C', 'member': 'load'},
|
|
);
|
|
final memberFixed = _finding(
|
|
rule: 'loading-flag-not-reset',
|
|
file: 'lib/bloc.dart',
|
|
line: 9,
|
|
data: const {'owner': 'C', 'member': 'refresh'},
|
|
);
|
|
final baseline = decodeBaselineEntries(
|
|
encodeBaseline('/root', [
|
|
wired,
|
|
deletedHandler,
|
|
deletedFile,
|
|
memberFixed,
|
|
]),
|
|
);
|
|
|
|
final verification = verifyAgainstBaseline(
|
|
report: FindingReport(
|
|
root: '/root',
|
|
findings: const [],
|
|
suppressed: const [],
|
|
),
|
|
baseline: baseline,
|
|
existingFiles: const {'lib/bloc.dart'},
|
|
declaredNames: const {'Wired', 'HandlerGone', 'B', 'C'},
|
|
handledEvents: const {'Wired'},
|
|
);
|
|
|
|
// Wired still has its handler and is simply no longer reported: a fix.
|
|
// refresh is a member label, which the declaration check cannot judge, so
|
|
// it stays resolved rather than being guessed either way.
|
|
expect(verification.resolved.map((e) => e.label), {'Wired', 'refresh'});
|
|
expect(verification.removed.map((e) => e.label), {'HandlerGone', 'load'});
|
|
expect(verification.allResolved, isTrue, reason: 'removed is still gone');
|
|
expect(verification.describe(), contains('REMOVED'));
|
|
expect(verification.toJson()['removed'], 2);
|
|
});
|
|
}
|