Skip to content

Commit 40e993a

Browse files
Label errors with the kind of mistake they show
Each error now carries a category from the LiquidJava bug codebook, kept internal for now (not printed): - Out of order: a call in a single state that does not allow it - Out of domain: a value outside its refinement - Path-dependent state: a state that differs between branches, when the class has one state set - Unprovable: nothing is known about the object's state or the value Errors the rules cannot decide get no category. The category picks the kind of state hints, so a hint never contradicts it: a join of every state is now path-dependent rather than unknown. The error message now lists its hints in a Hints section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 69895fa commit 40e993a

16 files changed

Lines changed: 328 additions & 39 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.Refinement;
4+
5+
public class ErrorOutOfDomainArgument {
6+
7+
static void take(@Refinement("_ > 0") int amount) {}
8+
9+
static void use() {
10+
take(-1); // Expect: Refinement Error
11+
}
12+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.StateRefinement;
4+
import liquidjava.specification.StateSet;
5+
6+
@StateSet({"closed", "opened"})
7+
@StateSet({"unlocked", "locked"})
8+
public class ErrorPathDependentOtherStateSet {
9+
10+
@StateRefinement(to = "closed(this) && unlocked(this)")
11+
public ErrorPathDependentOtherStateSet() {}
12+
13+
@StateRefinement(from = "closed(this)", to = "opened(this)")
14+
public void open() {}
15+
16+
@StateRefinement(from = "unlocked(this)", to = "locked(this)")
17+
public void lock() {}
18+
19+
@StateRefinement(from = "opened(this)")
20+
public void pass() {}
21+
22+
// the states differ only in the lock, which pass() does not check, so the path is not what is wrong
23+
public static void use(boolean secure) {
24+
ErrorPathDependentOtherStateSet d = new ErrorPathDependentOtherStateSet();
25+
if (secure)
26+
d.lock();
27+
d.pass(); // Expect: State Refinement Error
28+
}
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.StateRefinement;
4+
import liquidjava.specification.StateSet;
5+
6+
@StateSet({"created", "open"})
7+
public class ErrorStateHintJoinOfAllStates {
8+
9+
@StateRefinement(to = "created(this)")
10+
public ErrorStateHintJoinOfAllStates() {}
11+
12+
@StateRefinement(from = "created(this)", to = "open(this)")
13+
public void open() {}
14+
15+
@StateRefinement(from = "open(this)")
16+
public void read() {}
17+
18+
public static void use(boolean ready) {
19+
ErrorStateHintJoinOfAllStates f = new ErrorStateHintJoinOfAllStates();
20+
if (ready)
21+
f.open();
22+
f.read(); // Expect: State Refinement Error
23+
}
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.Refinement;
4+
5+
public class ErrorUnprovableArgument {
6+
7+
static void take(@Refinement("_ > 0") int amount) {}
8+
9+
// nothing is known about amount, so its refinement can be neither violated nor proved
10+
static void use(int amount) {
11+
take(amount); // Expect: Refinement Error
12+
}
13+
}

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/LJDiagnostic.java‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import java.util.Collections;
77
import java.util.List;
88
import java.util.Objects;
9+
import java.util.Optional;
910
import java.util.stream.Collectors;
1011

12+
import liquidjava.diagnostics.categories.ErrorCategory;
1113
import liquidjava.diagnostics.hints.Hint;
1214
import spoon.reflect.cu.SourcePosition;
1315

@@ -21,6 +23,7 @@ public class LJDiagnostic extends RuntimeException {
2123
private SourcePosition position;
2224
private final List<Hint> hints = new ArrayList<>();
2325
private String counterexample;
26+
private ErrorCategory category;
2427
private static final String PIPE = " | ";
2528

2629
public LJDiagnostic(String title, String message, SourcePosition pos, String accentColor, String customMessage) {
@@ -86,6 +89,14 @@ public void addHint(Hint hint) {
8689
hints.add(hint);
8790
}
8891

92+
public Optional<ErrorCategory> getCategory() {
93+
return Optional.ofNullable(category);
94+
}
95+
96+
public void setCategory(ErrorCategory category) {
97+
this.category = category;
98+
}
99+
89100
public void setCounterexampleStr(String counterexample) {
90101
this.counterexample = counterexample;
91102
}
@@ -110,10 +121,11 @@ public String toString() {
110121
sb.append(" --> ").append(String.join("\n ", counterexample.split("\n"))).append("\n");
111122
}
112123

113-
// hint
114-
String hint = getHint();
115-
if (hint != null && !hint.isBlank()) {
116-
sb.append(" --> ").append(String.join("\n ", hint.split("\n"))).append("\n");
124+
// hints
125+
if (!hints.isEmpty()) {
126+
sb.append("\nHints:\n");
127+
for (Hint hint : hints)
128+
sb.append(" - ").append(String.join("\n ", hint.message().split("\n"))).append("\n");
117129
}
118130

119131
// location
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package liquidjava.diagnostics.categories;
2+
3+
/**
4+
* The kind of mistake an error shows, named after the categories of the LiquidJava bug codebook. A category describes
5+
* the program according to its specification, so it cannot tell whether the specification itself is wrong.
6+
*/
7+
public enum ErrorCategory {
8+
9+
OUT_OF_ORDER("A1", "Out of order: the operation is legal for this object, but not right now"),
10+
OUT_OF_DOMAIN("A2", "Out of domain: the value is outside what is accepted"),
11+
PATH_DEPENDENT_STATE("C4.1", "Path-dependent state: the state differs depending on which branch ran"),
12+
UNPROVABLE("C4.2", "Unprovable: nothing is known that could prove the requirement");
13+
14+
private final String id;
15+
private final String title;
16+
17+
ErrorCategory(String id, String title) {
18+
this.id = id;
19+
this.title = title;
20+
}
21+
22+
/** The id of the category in the codebook */
23+
public String id() {
24+
return id;
25+
}
26+
27+
public String title() {
28+
return title;
29+
}
30+
}

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/errors/RefinementError.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.stream.Collectors;
66

77
import liquidjava.diagnostics.TranslationTable;
8+
import liquidjava.diagnostics.categories.ErrorCategory;
89
import liquidjava.diagnostics.hints.Hint;
910
import liquidjava.rj_language.Predicate;
1011
import liquidjava.rj_language.ast.Expression;
@@ -46,11 +47,19 @@ public RefinementError(SourcePosition position, SourcePosition declarationPositi
4647
.collect(Collectors.joining(" && "));
4748
setCounterexampleStr("Counterexample: " + counterexampleString);
4849
}
50+
setCategory(categoryOf(expected, found));
4951
if (isTrue(found.getImplication().toPredicate().getExpression()))
5052
addHint(Hint.general(
5153
"Not enough information to prove the expected refinement. Add a refinement or condition to constrain it."));
5254
}
5355

56+
/** Unprovable when what was found says nothing about the values the expected refinement constrains */
57+
private static ErrorCategory categoryOf(Predicate expected, VCSimplificationResult found) {
58+
List<String> foundVariables = found.getImplication().toPredicate().getVariableNames();
59+
boolean constrained = expected.getVariableNames().stream().anyMatch(foundVariables::contains);
60+
return constrained ? ErrorCategory.OUT_OF_DOMAIN : ErrorCategory.UNPROVABLE;
61+
}
62+
5463
@Override
5564
public SourcePosition getDeclarationPosition() {
5665
return declarationPosition;

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/errors/StateRefinementError.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.List;
66

77
import liquidjava.diagnostics.TranslationTable;
8+
import liquidjava.diagnostics.categories.ErrorCategory;
89
import liquidjava.diagnostics.hints.Hint;
910
import liquidjava.processor.VCImplication;
1011
import liquidjava.rj_language.Predicate;
@@ -23,7 +24,8 @@ public class StateRefinementError extends LJError {
2324
private final SourcePosition declarationPosition;
2425

2526
public StateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
26-
VCSimplificationResult found, TranslationTable translationTable, String customMessage, List<Hint> hints) {
27+
VCSimplificationResult found, TranslationTable translationTable, String customMessage, List<Hint> hints,
28+
ErrorCategory category) {
2729
super("State Refinement Error",
2830
String.format("found %s but expected %s",
2931
found.getImplication().toPredicate().getExpression().toDisplayString(),
@@ -33,6 +35,7 @@ public StateRefinementError(SourcePosition position, SourcePosition declarationP
3335
this.expected = expected;
3436
this.found = found;
3537
hints.forEach(this::addHint);
38+
setCategory(category);
3639
if (hints.isEmpty() && isTrue(found.getImplication().toPredicate().getExpression()))
3740
addHint(Hint.general(
3841
"No state information is known for this object. Make sure its state is initialized and available at this point."));

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/hints/StateHints.java‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.stream.Collectors;
1111
import java.util.stream.Stream;
1212

13+
import liquidjava.diagnostics.categories.ErrorCategory;
1314
import liquidjava.diagnostics.hints.Hint.Kind;
1415
import liquidjava.processor.context.RefinedFunction;
1516
import liquidjava.processor.context.Variable;
@@ -20,8 +21,9 @@
2021
import spoon.reflect.reference.CtTypeReference;
2122

2223
/**
23-
* Hints for a call made in a state that does not allow it, read from the class's {@link StateMachine}. Each hint only
24-
* states what the machine proves, so a suggested sequence of calls always makes the failing call verify.
24+
* Hints for a call made in a state that does not allow it, read from the class's {@link StateMachine}. The error's
25+
* category decides which kinds of hints are given, and each hint only states what the machine proves, so a suggested
26+
* sequence of calls always makes the failing call verify.
2527
*/
2628
public final class StateHints {
2729

@@ -44,19 +46,23 @@ private StateHints(StateMachine machine, String receiver) {
4446
* the states the receiver can be in before the call
4547
* @param receiver
4648
* the receiver as written in the code
49+
* @param category
50+
* the error's category, or {@code null} if it has none
4751
*/
48-
public static List<Hint> forCall(StateMachine machine, RefinedFunction call, Set<State> current, String receiver) {
52+
public static List<Hint> forCall(StateMachine machine, RefinedFunction call, Set<State> current, String receiver,
53+
ErrorCategory category) {
4954
if (!machine.models(call) || current.isEmpty() || machine.allows(current, call))
5055
return List.of(); // the machine cannot explain why the call failed
5156
Set<State> goal = machine.statesAllowing(call);
5257
if (goal.isEmpty())
5358
return List.of();
5459

5560
StateHints hints = new StateHints(machine, receiver);
56-
if (hints.isUnknown(current))
61+
if (category == ErrorCategory.UNPROVABLE)
5762
return hints.reachingCalls(call, goal);
58-
return Stream.concat(hints.howToReach(current, call, goal).stream(), hints.allowedCalls(current).stream())
59-
.toList();
63+
boolean pathDependent = category == ErrorCategory.PATH_DEPENDENT_STATE;
64+
return Stream.concat(hints.howToReach(current, call, goal, pathDependent).stream(),
65+
hints.allowedCalls(current).stream()).toList();
6066
}
6167

6268
/** Nothing is known about the receiver's state: list what leads to a state where {@code call} is allowed */
@@ -74,13 +80,13 @@ private List<Hint> reachingCalls(RefinedFunction call, Set<State> goal) {
7480
"The state of %s is unknown here. To get it to %s, use: %s", receiver, names(goal), list(ways))));
7581
}
7682

77-
private List<Hint> howToReach(Set<State> current, RefinedFunction call, Set<State> goal) {
83+
private List<Hint> howToReach(Set<State> current, RefinedFunction call, Set<State> goal, boolean pathDependent) {
7884
Optional<List<List<RefinedFunction>>> search = machine.shortestPaths(current, call, MAX_SEQUENCES);
7985
if (search.isEmpty())
8086
return List.of();
8187
List<List<RefinedFunction>> sequences = search.get();
8288
if (sequences.isEmpty())
83-
return machine.complete() ? List.of(unreachable(current, call, goal)) : List.of();
89+
return machine.complete() ? List.of(unreachable(current, call, goal, pathDependent)) : List.of();
8490
if (sequences.get(0).size() > MAX_SEQUENCE_LENGTH)
8591
return List.of();
8692
return sequences.stream().map(sequence -> callSequence(current, sequence)).toList();
@@ -95,8 +101,8 @@ private Hint callSequence(Set<State> current, List<RefinedFunction> sequence) {
95101
receiver, names(current), names(reached)));
96102
}
97103

98-
private Hint unreachable(Set<State> current, RefinedFunction call, Set<State> goal) {
99-
if (current.size() > 1)
104+
private Hint unreachable(Set<State> current, RefinedFunction call, Set<State> goal, boolean pathDependent) {
105+
if (pathDependent)
100106
return new Hint(Kind.PATH_DEPENDENT_STATE, String.format(
101107
"%s can be in %s here, depending on the path taken, and no sequence of calls moves all of them to %s. "
102108
+ "Make %s reach this line in the same state on every path",
@@ -125,11 +131,6 @@ private List<Hint> allowedCalls(Set<State> current) {
125131
return List.of();
126132
}
127133

128-
/** Whether nothing is known about the state of the receiver */
129-
private boolean isUnknown(Set<State> current) {
130-
return current.size() == machine.states().size();
131-
}
132-
133134
private String call(RefinedFunction method) {
134135
return receiver + "." + signature(method);
135136
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/TypeChecker.java‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import liquidjava.diagnostics.Diagnostics;
1111
import liquidjava.diagnostics.errors.*;
12+
import liquidjava.diagnostics.categories.ErrorCategory;
1213
import liquidjava.diagnostics.hints.Hint;
1314
import liquidjava.diagnostics.warnings.UnsatisfiableRefinementWarning;
1415
import liquidjava.processor.context.AliasWrapper;
@@ -425,12 +426,13 @@ public void throwRefinementError(SourcePosition position, SourcePosition declara
425426

426427
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
427428
Predicate expected, String customMessage) throws LJError {
428-
throwStateRefinementError(position, declarationPosition, found, expected, customMessage, List.of());
429+
throwStateRefinementError(position, declarationPosition, found, expected, customMessage, List.of(), null);
429430
}
430431

431432
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
432-
Predicate expected, String customMessage, List<Hint> hints) throws LJError {
433-
vcChecker.throwStateRefinementError(position, declarationPosition, found, expected, customMessage, hints);
433+
Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category) throws LJError {
434+
vcChecker.throwStateRefinementError(position, declarationPosition, found, expected, customMessage, hints,
435+
category);
434436
}
435437

436438
public void throwStateConflictError(SourcePosition position, Predicate expectedType) throws LJError {

0 commit comments

Comments
 (0)