import 'package:pipe/pipe.dart'; import 'package:test/test.dart'; Finding _finding({ required String rule, required PipeSeverity severity, String confidence = 'high', String? file, int? line, Map data = const {}, String? remediation, }) => Finding( rule: rule, severity: severity, confidence: confidence, message: 'message', file: file, line: line, remediation: remediation ?? 'fix $rule', data: data, ); FindingReport _report( List findings, { Set baseline = const {}, }) => FindingReport( root: '/root', findings: [ for (final finding in findings) baseline.contains(finding.fingerprint) ? finding.asBaselined() : finding, ], suppressed: const [], ); void main() { test('one root cause per union, not one per finding', () { final report = _report([ for (var i = 0; i < 5; i++) _finding( rule: ruleEmittedNotRendered, severity: PipeSeverity.warning, file: 'lib/a.dart', line: 10 + i, data: {'union': 'WideState', 'owner': 'WideBloc', 'variant': 'V$i'}, ), ]); final plan = buildWorkPlan(root: '/root', report: report); expect(plan.totalFindings, 5); expect(plan.totalCauses, 1); expect(plan.files, hasLength(1)); expect(plan.files.single.actions.single.resolves, 5); expect(plan.files.single.actions.single.label, 'WideState (WideBloc)'); expect(plan.files.single.actions.single.locations, hasLength(5)); }); test('severity dominates, then confidence, then breadth', () { final severe = _finding( rule: ruleEventWithoutHandler, severity: PipeSeverity.error, file: 'lib/err.dart', line: 1, data: {'event': 'Tapped'}, ); final wideWarning = _report([ for (var i = 0; i < 6; i++) _finding( rule: ruleEmittedNotRendered, severity: PipeSeverity.warning, file: 'lib/wide.dart', line: i + 1, data: {'union': 'WideState', 'owner': 'WideBloc', 'variant': 'V$i'}, ), ]).findings; final lowInfo = _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, confidence: 'low', file: 'lib/info.dart', line: 3, data: {'event': 'Never'}, ); final plan = buildWorkPlan( root: '/root', report: _report([...wideWarning, severe, lowInfo]), ); final ordered = plan.ordered; // An error outranks even a wide warning. expect(ordered.first.rule, ruleEventWithoutHandler); expect(ordered.first.severity, PipeSeverity.error); // A low-confidence info finding comes last. expect(ordered.last.severity, PipeSeverity.info); expect(ordered.last.confidence, 'low'); }); test('breadth raises a wide cause above a narrow one of equal severity', () { final narrow = _finding( rule: ruleEmittedNotRendered, severity: PipeSeverity.warning, file: 'lib/narrow.dart', line: 1, data: {'union': 'NarrowState', 'owner': 'B', 'variant': 'Only'}, ); final wide = [ for (var i = 0; i < 6; i++) _finding( rule: ruleEmittedNotRendered, severity: PipeSeverity.warning, file: 'lib/wide.dart', line: i + 1, data: {'union': 'WideState', 'owner': 'B', 'variant': 'V$i'}, ), ]; final ordered = buildWorkPlan( root: '/root', report: _report([narrow, ...wide]), ).ordered; expect(ordered.first.label, 'WideState (B)'); expect(ordered.first.score, greaterThan(ordered.last.score)); }); test('every score is explained, not just asserted', () { final plan = buildWorkPlan( root: '/root', report: _report([ _finding( rule: ruleEmittedNotRendered, severity: PipeSeverity.warning, file: 'lib/a.dart', line: 1, data: { 'union': 'S', 'owner': 'B', 'variant': 'Error', 'uxCritical': true, }, ), ]), ); final action = plan.ordered.single; expect(action.why, startsWith('${action.score} = ')); expect(action.why, contains('warning 50')); expect(action.why, contains('user-visible failure')); expect(action.verify, 'pipe check /root --rule $ruleEmittedNotRendered'); expect(action.action, isNotEmpty); }); test( 'files are ordered by total impact and actions descend within a file', () { final plan = buildWorkPlan( root: '/root', report: _report([ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/mixed.dart', line: 1, data: {'event': 'A'}, ), _finding( rule: ruleEventWithoutHandler, severity: PipeSeverity.error, file: 'lib/mixed.dart', line: 2, data: {'event': 'B'}, ), _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/small.dart', line: 1, data: {'event': 'C'}, ), ]), ); expect(plan.files.first.file, 'lib/mixed.dart'); final mixed = plan.files.first.actions; expect(mixed.first.severity, PipeSeverity.error); expect( mixed.first.score, greaterThanOrEqualTo(mixed.last.score), reason: 'actions within a file must be descending', ); }, ); test('a baselined repo yields an empty plan', () { final findings = [ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/a.dart', line: 1, data: {'event': 'A'}, ), ]; final report = _report( findings, baseline: {for (final finding in findings) finding.fingerprint}, ); final plan = buildWorkPlan(root: '/root', report: report); expect(plan.totalFindings, 0); expect(plan.files, isEmpty); expect(plan.ordered, isEmpty); }); test('the agent plan respects its budget and reports omissions', () { // Many distinct causes, small budget. final findings = [ for (var i = 0; i < 40; i++) _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/f$i.dart', line: 1, data: {'event': 'Event$i'}, ), ]; final plan = buildWorkPlan(root: '/root', report: _report(findings)); final block = plan.toAgentBlock(budgetChars: 800, maxActions: 40); expect(block.length, lessThanOrEqualTo(1000)); expect(block, contains('omitted to stay within 800')); expect(block, contains('# pipe plan')); }); test('the full plan does not claim truncation when nothing was dropped', () { final plan = buildWorkPlan( root: '/root', report: _report([ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/a.dart', line: 1, data: {'event': 'A'}, ), ]), ); final block = plan.toAgentBlock(budgetChars: 4000, maxActions: 10); expect(block, isNot(contains('omitted'))); expect(plan.describe(), contains('By file')); }); test('the plan states its coverage, whatever the budget', () { const coverage = ScanCoverage( discovered: 40, selected: 40, resolved: 37, failed: 1, skipped: 2, ); final plan = buildWorkPlan( root: '/root', report: FindingReport( root: '/root', findings: [ for (var i = 0; i < 30; i++) _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/f$i.dart', line: 1, data: {'event': 'Event$i'}, ), ], suppressed: const [], coverage: coverage, ), ); final block = plan.toAgentBlock(budgetChars: 300, maxActions: 30); expect(block, contains('37 of 40 discovered file(s) analysed')); expect(block, contains('PARTIAL')); expect(plan.toJson()['coverage'], coverage.toJson()); }); test('packing stops at the first cause that does not fit', () { // The top cause is too large for the budget. It used to be skipped while // smaller, lower-ranked causes were packed in behind it, so the plan read // as if they were the most important work. final plan = buildWorkPlan( root: '/root', report: _report([ _finding( rule: ruleEventWithoutHandler, severity: PipeSeverity.error, file: 'lib/${'nested/' * 80}big.dart', line: 1, data: {'event': 'Huge'}, ), for (var i = 0; i < 5; i++) _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/f$i.dart', line: 1, data: {'event': 'Small$i'}, ), ]), ); expect(plan.ordered.first.label, 'Huge'); final block = plan.toAgentBlock(budgetChars: 500, maxActions: 10); expect(block, isNot(contains('Small'))); expect(block, contains('6 further cause(s) omitted')); expect(block.length, lessThanOrEqualTo(500)); }); test('equal scores are ordered by file, line and rule, not by chance', () { // Sorting by score alone left ties in an order List.sort does not promise // to keep, so two runs could hand an agent a different first action. Finding dead(String file, int line, String event) => _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: file, line: line, data: {'event': event}, ); final plan = buildWorkPlan( root: '/root', report: _report([ dead('lib/z.dart', 1, 'Z'), dead('lib/a.dart', 9, 'A9'), dead('lib/a.dart', 2, 'A2'), dead('lib/m.dart', 5, 'M'), ]), ); expect(plan.ordered.map((action) => action.label), ['A2', 'A9', 'M', 'Z']); }); test('the agent plan opens with a caution about what its order means', () { final plan = buildWorkPlan( root: '/root', report: _report([ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/a.dart', line: 1, data: {'event': 'A'}, ), ]), ); expect(plan.toAgentBlock(), contains('Order is a score, not a mandate.')); }); test('wiring up a dead handler is not scored as judgement-free', () { // Under the default wire-up policy the fix is to add a control that // dispatches the event, which is a product decision; only removal needs // no judgement. final findings = [ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/a.dart', line: 1, data: {'event': 'A'}, ), ]; final wireUp = buildWorkPlan(root: '/root', report: _report(findings)); final remove = buildWorkPlan( root: '/root', report: _report(findings), policy: FixPolicy.remove, ); expect(wireUp.ordered.single.why, isNot(contains('no product judgement'))); expect( remove.ordered.single.why, contains('no product judgement needed (+15)'), ); expect(remove.ordered.single.score, wireUp.ordered.single.score + 15); }); test('a grouped action names every target, not only the first', () { final plan = buildWorkPlan( root: '/root', report: _report([ for (final event in ['Alpha', 'Beta', 'Gamma']) _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/bloc.dart', line: 1, data: {'union': 'Events', 'owner': 'Bloc', 'event': event}, remediation: 'Wire it up: dispatch $event from the UI.', ), ]), ); final action = plan.ordered.single; expect(action.resolves, 3); expect(action.action, contains('dispatch each of Alpha, Beta, Gamma')); }); test('the verify hint reruns the check the way the plan was made', () { final plan = buildWorkPlan( root: '/my app', report: _report([ _finding( rule: ruleDeadHandler, severity: PipeSeverity.info, file: 'lib/a.dart', line: 1, data: {'event': 'A'}, ), ]), baselinePath: '.pipe-baseline.json', directories: const ['lib', 'test'], ); expect( plan.ordered.single.verify, "pipe check '/my app' --rule dead-handler " '--baseline .pipe-baseline.json --dir lib --dir test', ); }); }