import 'dart:io'; import 'package:pipe/pipe.dart'; import 'package:test/test.dart'; const String fixturePath = 'test/fixtures/stuck_loading'; void main() { late ProjectAnalysis analysis; setUpAll(() async { final project = await loadProject(fixturePath); analysis = await analyzeProject(project, concurrency: 1); }); tearDownAll(() async { await analysis.dispose(); }); FindingReport reportWith(Set? baseline) => runChecks(flow: analysis.flow, baseline: baseline); test('encodes a baseline whose fingerprints cover every finding', () { final encoded = encodeBaseline( analysis.project.root, analysis.report.findings, ); final decoded = decodeBaseline(encoded); expect( decoded, hasLength(analysis.report.findings.length), reason: 'fingerprints should be unique per finding', ); expect(encoded, contains('"version": 2')); }); test('a baseline turns every existing finding into an accepted one', () { final decoded = decodeBaseline( encodeBaseline(analysis.project.root, analysis.report.findings), ); final baselined = reportWith(decoded); expect(baselined.newFindings, isEmpty); expect( baselined.baselinedFindings, hasLength(analysis.report.findings.length), ); // Severity gates must only look at NEW findings, or CI would stay red. expect( baselined.countBySeverity.values.every((count) => count == 0), isTrue, ); expect(baselined.countByRule, isEmpty); expect( baselined.toJson()['totals'], containsPair('baselined', analysis.report.findings.length), ); }); test('without a baseline nothing is marked as accepted', () { final fresh = reportWith(null); expect(fresh.baselinedFindings, isEmpty); expect(fresh.newFindings, hasLength(analysis.report.findings.length)); }); test('no fingerprint depends on the line its finding sits on', () { // Shifting only findings.first proved nothing about the other rules: three // of them fell back to `line=` and loading-not-reset embedded the catch's // absolute line, so moving code broke their baseline entries. expect(analysis.report.findings, isNotEmpty); for (final finding in analysis.report.findings) { final catchLine = finding.data['catchLine']; final moved = Finding( rule: finding.rule, severity: finding.severity, confidence: finding.confidence, message: finding.message, file: finding.file, line: (finding.line ?? 1) + 500, evidence: finding.evidence, remediation: finding.remediation, // Line-valued data moves with the code. data: { ...finding.data, if (catchLine is int) 'catchLine': catchLine + 500, }, ); expect( moved.fingerprint, finding.fingerprint, reason: '${finding.rule} at ${finding.location}', ); } }); test('inserting lines above every finding keeps every identity', () async { // The end-to-end version of the test above: really move the code, re-run // the analysis, and compare. Positional data derived from the AST (such as // a catch's place in its method) is exercised here, not simulated. final temp = Directory.systemTemp.createTempSync('pipe-shifted'); _copyTree(Directory(fixturePath), temp); for (final entity in Directory('${temp.path}/lib').listSync()) { if (entity is! File || !entity.path.endsWith('.dart')) continue; entity.writeAsStringSync('// shifted\n' * 7 + entity.readAsStringSync()); } final shifted = await analyzeProject( await loadProject(temp.path), concurrency: 1, ); try { final lines = { for (final finding in analysis.report.findings) if (finding.line != null) finding.line!, }; final shiftedLines = { for (final finding in shifted.report.findings) if (finding.line != null) finding.line!, }; expect(shiftedLines, isNot(lines), reason: 'the code must have moved'); expect( {for (final finding in shifted.report.findings) finding.fingerprint}, {for (final finding in analysis.report.findings) finding.fingerprint}, ); } finally { await shifted.dispose(); } }); test('a version-1 baseline still accepts the findings it recorded', () { // Fingerprints changed for rules that used to embed a line; an older // baseline must keep suppressing them rather than report them all as new. final legacy = { for (final finding in analysis.report.findings) finding.legacyFingerprint, }; final baselined = reportWith(legacy); expect(baselined.newFindings, isEmpty); final entries = [ for (final fingerprint in legacy) BaselineEntry( fingerprint: fingerprint, rule: fingerprint.split('|').first, file: fingerprint.split('|')[1], label: fingerprint, ), ]; final verification = verifyAgainstBaseline( report: analysis.report, baseline: entries, ); expect(verification.resolved, isEmpty); expect(verification.newFindings, isEmpty); }); test('a member name is a semantic key, so moving that code keeps identity', () { // notifier-never-notifies carries only {owner, member}; before `member` was // treated as discriminating, its fingerprint embedded the line number and // any code movement broke the baseline entry. final finding = analysis.report.findings.firstWhere( (f) => f.rule == ruleNotifierNeverNotifies, ); expect(finding.data.containsKey('member'), isTrue); final moved = Finding( rule: finding.rule, severity: finding.severity, confidence: finding.confidence, message: finding.message, file: finding.file, line: (finding.line ?? 1) + 900, evidence: finding.evidence, remediation: finding.remediation, data: finding.data, ); expect(moved.fingerprint, finding.fingerprint); }); test('two findings in one method still get distinct fingerprints', () { // A method can hold more than one swallowed catch, so this rule carries the // catch's index within its member: position enough to tell the two apart, // without the absolute line that broke identity whenever code moved. final catches = analysis.report.findings .where( (f) => f.rule == ruleLoadingNotReset && f.data['member'] == 'twoSwallowedAwaits', ) .toList(); expect(catches, hasLength(2)); expect( catches.map((f) => f.fingerprint).toSet(), hasLength(2), reason: 'a baseline must be able to tell the two catches apart', ); }); test('rejects a malformed baseline with an actionable error', () { expect(() => decodeBaseline('not json'), throwsA(isA())); expect( () => decodeBaseline('{"version":1}'), throwsA(isA()), ); }); } void _copyTree(Directory from, Directory to) { to.createSync(recursive: true); 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).writeAsBytesSync(entity.readAsBytesSync()); } } }