419 lines
12 KiB
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',
|
|
);
|
|
});
|
|
}
|