Skip to content

Commit 98fa854

Browse files
Use found … but expected … for refinement errors, drop the general hint, declare every from (#525)
Closes #520 Closes #521 ## What and why 1. **One headline frame (#520).** Refinement errors used `X is not a subtype of Y`, while state errors use `found X but expected Y`. Refinement errors now use `found %s but expected %s` too (`RefinementError`). An empty Found keeps `expected Y but could not prove it`. 2. **No general UNKNOWN hint (#521, part 1).** "Not enough information to prove the expected refinement. Add a refinement or condition to constrain it." is gone. Both fixes it suggests are graded wrong in the study (A2_04). 3. **Every `from` in declared-here (#521, part 2).** When Expected is the disjunction of several `@StateRefinement` entry states, the "Refinement declared here" excerpt marks every `from`, and each one gets its `where … means …` line. Before, `AuxStateHandler` took a `findFirst()`. - `StateRefinementError` now carries the list of declaration positions. `LJDiagnostic.getDeclarationPositions()` returns it, and `getDeclarationPosition()` still returns the first one, so the IDE's Locations and State Machine are unchanged. - Fixing this exposed a position bug: each `from` was found by its text in *any* LiquidJava annotation of the method. In A1_01, line 111's `from = "compressionTypeSet(this)"` was placed at line 110's `to = "compressionTypeSet(this)"`. `from`/`to` positions now come from the annotation's own value, and the text search is only a fallback. Soundness: no message claims anything new. The excerpt and `where` lines quote the spec, and the Expected they explain is already the disjunction of these same `from`s. ## Study tasks: before (main) → after Each task still gives exactly one error, on its `reportedLine`. A1_38, A1_56 and A1_09 are unchanged. **A2_10** ```diff - Refinement Error: failures.size() == size(this.failures) && failures.size() >= 0 is not a subtype of failures.size() > 0 + Refinement Error: found failures.size() == size(this.failures) && failures.size() >= 0 but expected failures.size() > 0 ``` **practice** ```diff - Refinement Error: toPay == price - discount is not a subtype of toPay >= 0 + Refinement Error: found toPay == price - discount but expected toPay >= 0 ``` **practice2** ```diff - Refinement Error: delay == atMinute - nowMinute is not a subtype of delay >= 0 + Refinement Error: found delay == atMinute - nowMinute but expected delay >= 0 ``` **A2_04**: the headline is unchanged (`expected timeout >= 0 but could not prove it`), and the hint block is gone: ```diff - Hints: - - Not enough information to prove the expected refinement. Add a refinement or condition to constrain it. ``` **A1_01**: declared-here, after: ``` --> Refinement declared here: 109 | */ 110 | @StateRefinement(from = "compressionExplicit(this)", to = "compressionTypeSet(this)") | ^^^^^^^^^^^^^^^^^^^^^^^^^ 111 | @StateRefinement(from = "compressionTypeSet(this)", to = "compressionTypeSet(this)") | ^^^^^^^^^^^^^^^^^^^^^^^^ 112 | void setCompressionType(String compressionType); where compressionExplicit(this) means compression(this) == compressionExplicit where compressionTypeSet(this) means compression(this) == compressionTypeSet ``` Before, only line 110 was marked, and only its `where` line was shown. ## Tests - Updated the 6 expectations that assert `is not a subtype of` (TestFoundBinders, TestStaticConstantNames, RefinementErrorCounterexampleTest). - `RefinementErrorCounterexampleTest.messageWithNothingFoundGivesNoHint`: an empty Found gives no hint. - `TestDeclarationPositions.stateErrorPointsToEveryEntryStateWithWhatItMeans` with the new fixture `ErrorStateRefinementEveryFrom.java`, a named set with two `from`s where the second `from` repeats the first annotation's `to`. It checks both positions and both `where` lines. - Each new or changed test fails on main. `mvn test`: 731 tests, 0 failures. ## Study env: re-run after release (not edited here) - `test/verify-tasks.py` → `test/out/verify-new.json`: the practice, practice2 and A2_10 headlines, and the A2_04 hint. - The recorded CLI outputs of practice and practice2, and of A2_10, A2_04 and A1_01. - Tour screenshots (`test/e2e.mjs`): `test/out/B-01…B-07-tour-step*.png` (the tour runs on the practice file, whose headline changes). - Practice hover/repair screenshots: `B-02-practice2-hover.png`, `B-11-practice2-hover.png`, `B-01/B-10-practice2-repair.png`, and the block-1 practice equivalents. - A2_10: `A-run2/A-03-A2_10-hover.png`, `A-run2/A-04-A2_10-liquidjava-view.png` (headline). - A2_04: `A-19-A2_04-hover.png`, `A-20-A2_04-liquidjava-view.png` (hint removed). - A1_01: the hover and panel are unchanged. The IDE gets only the first declaration position, so showing both `from`s in the panel needs an extension change (it can read `getDeclarationPositions()`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c4c6ce1 commit 98fa854

11 files changed

Lines changed: 150 additions & 64 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.StateRefinement;
4+
import liquidjava.specification.StateSet;
5+
6+
// the call needs either entry state: the error declares both, each where its annotation writes it
7+
@StateSet(name = "door", value = {"shut", "ajar", "wide"})
8+
public class ErrorStateRefinementEveryFrom {
9+
10+
@StateRefinement(to = "wide(this)")
11+
public ErrorStateRefinementEveryFrom() {}
12+
13+
@StateRefinement(from = "shut(this)", to = "ajar(this)")
14+
@StateRefinement(from = "ajar(this)", to = "ajar(this)")
15+
public void push() {}
16+
17+
public static void main(String[] args) {
18+
new ErrorStateRefinementEveryFrom().push(); // Expect: State Refinement Error
19+
}
20+
}

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

Lines changed: 50 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ public SourcePosition getDeclarationPosition() {
7070
return null;
7171
}
7272

73+
/** Where each part of the refinement is declared, the first being {@link #getDeclarationPosition()} */
74+
public List<SourcePosition> getDeclarationPositions() {
75+
SourcePosition declarationPosition = getDeclarationPosition();
76+
return declarationPosition == null ? List.of() : List.of(declarationPosition);
77+
}
78+
7379
/** Lines printed under the message that explain it, such as what the names it uses mean */
7480
public List<String> getMessageNotes() {
7581
return List.of();
@@ -149,7 +155,7 @@ public String toString() {
149155
SourcePosition declPos = getDeclarationPosition();
150156
if (declPos != null && declPos.getFile() != null && !declPos.equals(position)) {
151157
sb.append(Colors.CYAN).append("\n--> Refinement declared here:\n").append(Colors.RESET);
152-
String declarationSnippet = getSnippet(declPos, 1, 1, Colors.CYAN, null, true);
158+
String declarationSnippet = getSnippet(getDeclarationPositions(), 1, 1, Colors.CYAN, null, true);
153159
if (declarationSnippet != null) {
154160
sb.append(declarationSnippet);
155161
for (String note : getDeclarationNotes())
@@ -164,22 +170,27 @@ public String toString() {
164170
}
165171

166172
public String getSnippet() {
167-
return getSnippet(position, 2, 2, accentColor, customMessage, false);
173+
return position == null ? null : getSnippet(List.of(position), 2, 2, accentColor, customMessage, false);
168174
}
169175

170-
private String getSnippet(SourcePosition snippetPosition, int contextBefore, int contextAfter, String markerColor,
176+
// One excerpt of the file of the first position, marking each position in that file
177+
private String getSnippet(List<SourcePosition> positions, int contextBefore, int contextAfter, String markerColor,
171178
String markerMessage, boolean firstLineOnly) {
172-
if (snippetPosition == null || snippetPosition.getFile() == null)
179+
if (positions.isEmpty() || positions.get(0).getFile() == null)
173180
return null;
174181

175-
Path path = snippetPosition.getFile().toPath();
182+
Path path = positions.get(0).getFile().toPath();
183+
List<SourcePosition> marked = positions.stream()
184+
.filter(p -> p.getFile() != null && p.getFile().toPath().equals(path)).toList();
176185
try {
177186
List<String> lines = Files.readAllLines(path);
178187
StringBuilder sb = new StringBuilder();
179188

180-
int startLine = Math.max(1, snippetPosition.getLine() - contextBefore);
181-
int highlightedEndLine = firstLineOnly ? snippetPosition.getLine() : snippetPosition.getEndLine();
182-
int endLine = Math.min(lines.size(), highlightedEndLine + contextAfter);
189+
int startLine = Math.max(1,
190+
marked.stream().mapToInt(SourcePosition::getLine).min().getAsInt() - contextBefore);
191+
int endLine = Math.min(lines.size(),
192+
marked.stream().mapToInt(p -> highlightedEndLine(p, firstLineOnly)).max().getAsInt()
193+
+ contextAfter);
183194

184195
// calculate padding for line numbers
185196
int padding = String.valueOf(endLine).length();
@@ -193,31 +204,34 @@ private String getSnippet(SourcePosition snippetPosition, int contextBefore, int
193204
sb.append(Colors.GREY).append(lineNumStr).append(PIPE).append(line).append(Colors.RESET).append("\n");
194205

195206
// add error markers on the line(s) with the error
196-
if (i >= snippetPosition.getLine() && i <= highlightedEndLine) {
197-
int colStart = (i == snippetPosition.getLine()) ? snippetPosition.getColumn() : 1;
198-
int colEnd = (i == snippetPosition.getEndLine()) ? snippetPosition.getEndColumn()
199-
: rawLine.length();
200-
201-
if (colStart > 0 && colEnd > 0) {
202-
int tabsBeforeStart = (int) rawLine.substring(0, Math.max(0, colStart - 1)).chars()
203-
.filter(ch -> ch == '\t').count();
204-
int tabsBeforeEnd = (int) rawLine.substring(0, Math.max(0, colEnd)).chars()
205-
.filter(ch -> ch == '\t').count();
206-
int visualColStart = colStart + tabsBeforeStart * 3;
207-
int visualColEnd = colEnd + tabsBeforeEnd * 3;
208-
209-
// line number padding + pipe + column offset
210-
String indent = " ".repeat(padding) + Colors.GREY + PIPE + Colors.RESET
211-
+ " ".repeat(visualColStart - 1);
212-
String markers = markerColor + "^".repeat(Math.max(1, visualColEnd - visualColStart + 1));
213-
sb.append(indent).append(markers);
214-
215-
// custom message
216-
if (markerMessage != null && !markerMessage.isBlank()) {
217-
String offset = " ".repeat(padding + visualColEnd + PIPE.length() + 1);
218-
sb.append(" " + markerMessage.replace("\n", "\n" + offset));
207+
for (SourcePosition snippetPosition : marked) {
208+
int highlightedEndLine = highlightedEndLine(snippetPosition, firstLineOnly);
209+
if (i >= snippetPosition.getLine() && i <= highlightedEndLine) {
210+
int colStart = (i == snippetPosition.getLine()) ? snippetPosition.getColumn() : 1;
211+
int colEnd = (i == snippetPosition.getEndLine()) ? snippetPosition.getEndColumn()
212+
: rawLine.length();
213+
214+
if (colStart > 0 && colEnd > 0) {
215+
int tabsBeforeStart = (int) rawLine.substring(0, Math.max(0, colStart - 1)).chars()
216+
.filter(ch -> ch == '\t').count();
217+
int tabsBeforeEnd = (int) rawLine.substring(0, Math.max(0, colEnd)).chars()
218+
.filter(ch -> ch == '\t').count();
219+
int visualColStart = colStart + tabsBeforeStart * 3;
220+
int visualColEnd = colEnd + tabsBeforeEnd * 3;
221+
222+
// line number padding + pipe + column offset
223+
String indent = " ".repeat(padding) + Colors.GREY + PIPE + Colors.RESET
224+
+ " ".repeat(visualColStart - 1);
225+
String markers = markerColor + "^".repeat(Math.max(1, visualColEnd - visualColStart + 1));
226+
sb.append(indent).append(markers);
227+
228+
// custom message
229+
if (markerMessage != null && !markerMessage.isBlank()) {
230+
String offset = " ".repeat(padding + visualColEnd + PIPE.length() + 1);
231+
sb.append(" " + markerMessage.replace("\n", "\n" + offset));
232+
}
233+
sb.append(Colors.RESET).append("\n");
219234
}
220-
sb.append(Colors.RESET).append("\n");
221235
}
222236
}
223237
}
@@ -227,6 +241,10 @@ private String getSnippet(SourcePosition snippetPosition, int contextBefore, int
227241
}
228242
}
229243

244+
private static int highlightedEndLine(SourcePosition position, boolean firstLineOnly) {
245+
return firstLineOnly ? position.getLine() : position.getEndLine();
246+
}
247+
230248
@Override
231249
public boolean equals(Object obj) {
232250
if (this == obj)

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import liquidjava.diagnostics.AliasExpansion;
1414
import liquidjava.diagnostics.TranslationTable;
1515
import liquidjava.diagnostics.categories.ErrorCategory;
16-
import liquidjava.diagnostics.hints.Hint;
1716
import liquidjava.processor.context.AliasWrapper;
1817
import liquidjava.rj_language.Predicate;
1918
import liquidjava.rj_language.ast.BinaryExpression;
@@ -54,7 +53,7 @@ public RefinementError(SourcePosition position, SourcePosition declarationPositi
5453
String customMessage, List<AliasWrapper> aliases) {
5554
super("Refinement Error",
5655
ErrorMessage.of(expected.getExpression(), found, translationTable, shown -> !isTrue(shown),
57-
"%s is not a subtype of %s", UnaryOperator.identity()),
56+
"found %s but expected %s", UnaryOperator.identity()),
5857
position, translationTable, customMessage);
5958
this.expected = expected;
6059
this.found = found;
@@ -72,9 +71,6 @@ public RefinementError(SourcePosition position, SourcePosition declarationPositi
7271
setCounterexampleStr("Counterexample: " + counterexampleString);
7372
}
7473
setCategory(categoryOf(expected, found));
75-
if (isTrue(found.getImplication().toPredicate().getExpression()))
76-
addHint(Hint.general(
77-
"Not enough information to prove the expected refinement. Add a refinement or condition to constrain it."));
7874
}
7975

8076
// Each alias the message uses once, with those their definitions use, as declared

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,27 @@ public class StateRefinementError extends LJError {
2424

2525
private final Predicate expected;
2626
private final VCSimplificationResult found;
27-
private final SourcePosition declarationPosition;
27+
private final List<SourcePosition> declarationPositions;
2828
private final StateErrorContext context;
2929
private final List<String> declarationNotes;
3030

3131
/**
32+
* @param declarationPositions
33+
* where each entry state that makes up expected is declared
3234
* @param display
3335
* rewrites expected and found as the error shows them, here and to the IDE, after the facts to show are
3436
* chosen on them as given
3537
* @param declarationNotes
3638
* lines that explain the declaration, printed under its excerpt
3739
*/
38-
public StateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
40+
public StateRefinementError(SourcePosition position, List<SourcePosition> declarationPositions, Predicate expected,
3941
VCSimplificationResult found, TranslationTable translationTable, String customMessage, List<Hint> hints,
4042
ErrorCategory category, StateErrorContext context, UnaryOperator<Expression> display,
4143
List<String> declarationNotes) {
4244
super("State Refinement Error", ErrorMessage.of(expected.getExpression(), found, translationTable,
4345
StateRefinementError::appliesGhost, "found %s but expected %s", display), position, translationTable,
4446
customMessage);
45-
this.declarationPosition = declarationPosition;
47+
this.declarationPositions = declarationPositions;
4648
this.expected = new Predicate(display.apply(expected.getExpression()));
4749
this.found = found.map(display);
4850
this.context = context;
@@ -68,7 +70,12 @@ public List<String> getDeclarationNotes() {
6870

6971
@Override
7072
public SourcePosition getDeclarationPosition() {
71-
return declarationPosition;
73+
return declarationPositions.isEmpty() ? null : declarationPositions.get(0);
74+
}
75+
76+
@Override
77+
public List<SourcePosition> getDeclarationPositions() {
78+
return declarationPositions;
7279
}
7380

7481
public Predicate getExpected() {

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -613,14 +613,14 @@ public void throwRefinementError(SourcePosition position, SourcePosition declara
613613

614614
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
615615
Predicate expected, String customMessage) throws LJError {
616-
throwStateRefinementError(position, declarationPosition, found, expected, customMessage, List.of(), null, null,
617-
List.of());
616+
throwStateRefinementError(position, List.of(declarationPosition), found, expected, customMessage, List.of(),
617+
null, null, List.of());
618618
}
619619

620-
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
621-
Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category,
620+
public void throwStateRefinementError(SourcePosition position, List<SourcePosition> declarationPositions,
621+
Predicate found, Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category,
622622
StateErrorContext context, List<String> declarationNotes) throws LJError {
623-
vcChecker.throwStateRefinementError(position, declarationPosition, found, expected, customMessage, hints,
623+
vcChecker.throwStateRefinementError(position, declarationPositions, found, expected, customMessage, hints,
624624
category, context, declarationNotes);
625625
}
626626

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,15 +455,15 @@ protected Predicate shownFound(Predicate found, Predicate expected) {
455455
return buildPremiseChain(new TranslationTable(), expected, found).simplify().getImplication().toPredicate();
456456
}
457457

458-
protected void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition,
458+
protected void throwStateRefinementError(SourcePosition position, List<SourcePosition> declarationPositions,
459459
Predicate found, Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category,
460460
StateErrorContext context, List<String> declarationNotes) throws StateRefinementError {
461461
TranslationTable map = new TranslationTable();
462462
VCImplication foundState = buildPremiseChain(map, expected, found);
463463
VCSimplificationResult simplified = foundState.simplify(NeededVariables.forStateError(expected));
464464
VCSimplificationResult shown = VCSimplification.simplifyOnce(simplified,
465465
new VCRelevantFacts(expected.getExpression(), this::ghostFunctionsOf));
466-
throw new StateRefinementError(position, declarationPosition, expected, shown, map, customMessage, hints,
466+
throw new StateRefinementError(position, declarationPositions, expected, shown, map, customMessage, hints,
467467
category, context,
468468
e -> StateValues.toSetForm(e, this.context.getGhostStates(), this.context.getGhosts()),
469469
declarationNotes);

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/object_checkers/AuxStateHandler.java‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,13 @@ private static ObjectState getStates(CtAnnotation<? extends Annotation> ctAnnota
417417
Predicate written = new Predicate(from, e, prefix);
418418
state.setWrittenFrom(written);
419419
state.setFrom(createStatePredicate(written, from, f.getTargetClass(), tc, e, false));
420-
state.setFromPosition(Utils.getLJAnnotationPosition(e, from));
420+
state.setFromPosition(valuePosition(m.get("from"), e, from));
421421
}
422422

423423
// has to
424424
if (to != null) {
425425
state.setTo(createStatePredicate(new Predicate(to, e, prefix), to, f.getTargetClass(), tc, e, true));
426-
state.setToPosition(Utils.getLJAnnotationPosition(e, to));
426+
state.setToPosition(valuePosition(m.get("to"), e, to));
427427
}
428428

429429
// has to but not from, state enters with true
@@ -433,6 +433,12 @@ private static ObjectState getStates(CtAnnotation<? extends Annotation> ctAnnota
433433
return state;
434434
}
435435

436+
// Where this annotation writes a literal value, since another annotation of the element may write the same text
437+
private static SourcePosition valuePosition(CtExpression<?> value, CtElement element, String written) {
438+
SourcePosition position = value instanceof CtLiteral<?> ? Utils.getAnnotationValuePosition(value) : null;
439+
return position != null ? position : Utils.getLJAnnotationPosition(element, written);
440+
}
441+
436442
/**
437443
* Creates the predicate for state transition
438444
*
@@ -796,12 +802,16 @@ private static void changeState(TypeChecker tc, VariableInstance vi, RefinedFunc
796802
// combine messages of all state changes
797803
String message = stateChanges.stream().map(ObjectState::getMessage)
798804
.filter(msg -> msg != null && !msg.isBlank()).distinct().collect(Collectors.joining("\n"));
799-
Optional<ObjectState> declaration = stateChanges.stream().filter(ObjectState::hasFrom)
800-
.filter(s -> s.getFromPosition() != null).findFirst();
801-
SourcePosition declarationPosition = declaration.map(ObjectState::getFromPosition)
802-
.orElse(function.getPlacementInCode().getPosition());
803-
List<String> declarationNotes = declaration.map(ObjectState::getWrittenFrom).filter(Objects::nonNull)
804-
.map(written -> declarationNotes(written.getExpression(), tc.getContext())).orElse(List.of());
805+
// every entry state that makes up expected, each with what its states mean
806+
List<ObjectState> declarations = stateChanges.stream().filter(ObjectState::hasFrom)
807+
.filter(s -> s.getFromPosition() != null).toList();
808+
List<SourcePosition> declarationPositions = declarations.isEmpty()
809+
? List.of(function.getPlacementInCode().getPosition())
810+
: declarations.stream().map(ObjectState::getFromPosition).toList();
811+
List<String> declarationNotes = declarations.stream().map(ObjectState::getWrittenFrom)
812+
.filter(Objects::nonNull)
813+
.flatMap(written -> declarationNotes(written.getExpression(), tc.getContext()).stream()).distinct()
814+
.toList();
805815
List<GhostState> ghostStates = getGhostStatesFor(function.getTargetClass(), tc);
806816
Optional<DecidingArgument> deciding = decidingArgument(tc, vi, function, ghostStates, invocation);
807817
Optional<ReceiverStates> states = receiverStates(tc, function, vi, prevState, invocation);
@@ -817,7 +827,7 @@ private static void changeState(TypeChecker tc, VariableInstance vi, RefinedFunc
817827
StateErrorContext context = states
818828
.map(r -> StateErrorContext.of(r.machine(), function, r.current(), writtenReceiver(invocation)))
819829
.orElse(null);
820-
tc.throwStateRefinementError(invocation.getPosition(), declarationPosition, prevState,
830+
tc.throwStateRefinementError(invocation.getPosition(), declarationPositions, prevState,
821831
expectedStatesDisjunction, message, hints, category, context, declarationNotes);
822832
}
823833
}

0 commit comments

Comments
 (0)