pipe/test/suggest_test.dart

419 lines
12 KiB
Dart

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<String, Object?> 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<Finding> findings, {
Set<String> 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',
);
});
}