fix: stop verify, reports and briefs from claiming more than pipe checked #1

Merged
trevon merged 12 commits from fix/verify-soundness-and-injection into main 2026-09-22 18:24:06 +00:00
2 changed files with 47 additions and 2 deletions
Showing only changes of commit 17c9f9afab - Show all commits

fix(suggest): break score ties by file, line and rule

The plan sorted by score alone. List.sort is not stable, so equal-score
actions came out in an order nothing guaranteed, and two runs over the same
code could hand an agent a different first action. Ties now fall back to file,
line, rule and label, both in the global order and within each file.
trvon 2026-09-22 11:57:49 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -14,6 +14,8 @@ class FixAction {
required this.resolves,
required this.score,
required this.why,
this.file = '',
this.line,
});
final String rule;
@ -36,6 +38,10 @@ class FixAction {
/// Human-readable breakdown of the score, so the order is not a black box.
final String why;
/// Where the action's first finding is: the tiebreak between equal scores.
final String file;
final int? line;
String get verify => 'pipe check --rule $rule';
Map<String, Object?> toJson() => {
@ -91,7 +97,7 @@ class WorkPlan {
/// Global ordering of causes, highest impact first.
List<FixAction> get ordered {
final all = [for (final plan in files) ...plan.actions];
all.sort((a, b) => b.score.compareTo(a.score));
all.sort(_byImpact);
return all;
}
@ -208,6 +214,21 @@ class WorkPlan {
}
}
/// Highest score first, then file, line and rule. List.sort is not stable, so
/// sorting by score alone left equal-score actions in an arbitrary order that
/// could differ between runs - and the agent plan is read top-down.
int _byImpact(FixAction a, FixAction b) {
final byScore = b.score.compareTo(a.score);
if (byScore != 0) return byScore;
final byFile = a.file.compareTo(b.file);
if (byFile != 0) return byFile;
final byLine = (a.line ?? 0).compareTo(b.line ?? 0);
if (byLine != 0) return byLine;
final byRule = a.rule.compareTo(b.rule);
if (byRule != 0) return byRule;
return a.label.compareTo(b.label);
}
/// Severity is the dominant term: it is what distinguishes a broken
/// interaction from a tidiness issue.
const Map<PipeSeverity, int> _severityBase = {
@ -363,6 +384,8 @@ WorkPlan buildWorkPlan({required String root, required FindingReport report}) {
resolves: group.length,
score: score,
why: _whyOf(group, score),
file: file,
line: head.line,
);
byFile.putIfAbsent(file, () => []).add(action);
fileScores.update(file, (value) => value + score, ifAbsent: () => score);
@ -370,7 +393,7 @@ WorkPlan buildWorkPlan({required String root, required FindingReport report}) {
final files = <FilePlan>[];
for (final entry in byFile.entries) {
final actions = entry.value..sort((a, b) => b.score.compareTo(a.score));
final actions = entry.value..sort(_byImpact);
files.add(
FilePlan(
file: entry.key,

View file

@ -309,4 +309,26 @@ void main() {
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']);
});
}