pipe/test/fingerprint_test.dart
trvon c79878f93d
fix(baseline): make every fingerprint independent of position
The fingerprint is documented as surviving line shifts, but eight of the
fixture's findings carried a line number, so moving code down a file
turned accepted findings into new ones on the next ratcheted check:

- dormant emitted-not-rendered (keyed by union + owner, neither counted
  as identifying, so the line was appended)
- notifier-inert-consumer and notifier-field-unread (owner only)
- loading-not-reset, whose `catchLine` key is a line by another name

`union` and `owner` now count as identifying (each of those rules emits at
most one finding per union or notifier per file), and loading-not-reset
keys on the catch's ordinal within its member. Its `on<E>` handler
closures are also named `on<E>` like loading-flag-not-reset's, so the
ordinal cannot collide across handlers.

The existing move test only bumped `line`, not `catchLine` in the data,
so it passed while real moves did not. fingerprint_test.dart now shifts
every file of the fixture for real and requires (a) every fingerprint to
survive and (b) no two findings to share one. (a) fails on the previous
rules.dart with exactly the eight findings above; (b) passed there too, and
guards the ordinal and closure naming that replace the line.

Baselines written before this change see the affected findings once as
new; re-accept them with --write-baseline. No committed baselines exist
in the dogfood repos.
2026-09-22 13:47:40 -06:00

84 lines
2.8 KiB
Dart

import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// The fixture copied with [padding] blank lines inserted at the top of every
/// Dart source, so every finding moves down without any semantic change.
String _shiftedFixture(int padding) {
final from = Directory('test/fixtures/stuck_loading');
final to = Directory.systemTemp.createTempSync('pipe-shifted');
for (final entity in from.listSync(recursive: true)) {
final relative = entity.path.substring(from.path.length + 1);
final target = '${to.path}/$relative';
if (entity is Directory) {
Directory(target).createSync(recursive: true);
} else if (entity is File) {
File(target).createSync(recursive: true);
if (entity.path.endsWith('.dart')) {
// After any `library;` / import header would change nothing; a comment
// block at the very top shifts every line in the file.
File(target).writeAsStringSync(
'${List.filled(padding, '//').join('\n')}\n'
'${entity.readAsStringSync()}',
);
} else {
File(target).writeAsBytesSync(entity.readAsBytesSync());
}
}
}
return to.path;
}
Future<List<Finding>> _findings(String root) async {
final analysis = await analyzeProject(
await loadProject(root),
concurrency: 1,
);
try {
return analysis.report.findings;
} finally {
await analysis.dispose();
}
}
void main() {
late List<Finding> original;
late List<Finding> shifted;
setUpAll(() async {
original = await _findings('test/fixtures/stuck_loading');
shifted = await _findings(_shiftedFixture(7));
});
test('moving code down a file does not change any fingerprint', () {
// A baseline must keep accepting a finding when unrelated edits move it.
// Any fingerprint carrying a line number resurrected accepted findings as
// new on the next check.
expect(
shifted.map((f) => f.line),
isNot(equals(original.map((f) => f.line))),
reason: 'the control must actually move the findings',
);
final before = {for (final f in original) f.fingerprint: f.rule};
final after = {for (final f in shifted) f.fingerprint: f.rule};
final moved = {
for (final entry in before.entries)
if (!after.containsKey(entry.key)) entry.key: entry.value,
};
expect(moved, isEmpty, reason: 'fingerprints that depend on position');
});
test('no two findings share a fingerprint', () {
// A collision makes the baseline keep one finding and silently drop the
// other, so a second real defect can never be reported as new.
final counts = <String, int>{};
for (final f in original) {
counts[f.fingerprint] = (counts[f.fingerprint] ?? 0) + 1;
}
expect({
for (final e in counts.entries)
if (e.value > 1) e.key: e.value,
}, isEmpty);
});
}