pipe/test/verify_test.dart
trvon 4f3f22acad
Some checks failed
Lint Commits / commitlint (pull_request) Successful in 15s
Tests / Static Analysis (pull_request) Has been cancelled
Tests / Tests (pull_request) Has been cancelled
feat(verify): report a deleted subject as removed, not resolved
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.
2026-09-24 20:04:52 -06:00

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