Skip to content

Give state hints when the state set or methods come from the receiver's supertypes - #464

Merged
CatarinaGamboa merged 7 commits into
mainfrom
fix/456-inherited-state-hints
Nov 3, 2026
Merged

CatarinaGamboa merged 7 commits into
mainfrom
fix/456-inherited-state-hints

Conversation

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator

Fixes #456.

State hints come from a state machine. Until now, that machine was built only from the class the failing method is specified for. When the @StateSet is in a superclass's spec (Window) and the method is in a subclass's spec (Frame), the machine had no states, so the error had no hints.

The machine is now built for the receiver's static type. It includes the state sets of that type and of all its supertypes, and the refinements a call on that type actually uses, matching how the verifier picks them: the receiver type's own refinement first, otherwise the one for the nearest type that declares the method. If it can't tell which refinement the verifier applies, it leaves the method out and marks the machine incomplete, so hints never suggest a call that wouldn't verify and never claim "no sequence of calls" when unsure.

Issue example, after the change:

State Refinement Error: found displayable(host) but expected notDisplayable(host)
Hints:
  - Call host.dispose() before this call to move host from displayable to notDisplayable
  - In state displayable, host can call: dispose(), pack()

Cases covered (TestStateHints)

Case Hints
State set in Window's spec, method in Frame's spec (the issue) Call host.dispose() …, can call: dispose(), pack()
JFrame receiver with no spec of its own (grandchild) same, for window
Frame redeclares addNotify in its bytecode, Window's spec refines it addNotify() not suggested, (and possibly others)
Source subclass adds a transition on its parent's states Call s.bc() … (no hints before)
Subclass overrides with a stronger transition uses the override: Call s.next() … from a to c
Parent-typed receiver, a subclass has transitions still no "unreachable" claim
Stack spec refines add(E), which Stack inherits from Vector not suggested (the verifier never applies it)
User class named Collection next to an ArrayList spec its states are left out, hints kept

Other changes

  • Duplicate state sets. A class's state set can be registered twice in the context, which doubled the machine's states. States are now deduped by qualified name. They are also filtered by their owning class, so a user class sharing a simple name with a supertype doesn't contribute states.
  • Crash fix. AuxHierarchyRefinementsPassage.transferStateRefinements indexed the method's parameters with the index of the state refinement, so overriding a parameterless state-refined method crashed with IndexOutOfBoundsException. The method itself is now the placement element. This is only part of Crash when an override has fewer state refinements than the method it overrides #462, which is still open.
  • Constructor suggestions. Repeated constructors are listed once, and constructors of abstract classes and interfaces are no longer suggested.
  • Existing fixtures. I diffed the output of all 175 *error* fixtures against main. Eight changed, all for the better: no more suggestions like new Throwable(String) for an Exception-typed receiver (it wouldn't type-check), and new "No sequence of calls" hints where they're true.

Not in this PR

Testing

./mvnw -pl liquidjava-verifier test: 548 tests, 0 failures, run locally.

🤖 Generated with Claude Code

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex review] The inherited-method machine can suggest a call the receiver cannot make. Reproducer on this PR head: HintParent has states closed/open, a private @StateRefinement(from = "closed(this)", to = "open(this)") void open(), and a public use() requiring open; HintChild extends HintParent; bad(HintChild child) { child.use(); }. The verifier correctly reports the error but hints: To get it to open, use: child.open() (from closed). child.open() does not compile because the inherited method is private. resolvesTo checks method name and arity, then admits the transition without checking accessibility. Please leave inaccessible inherited methods out of suggested calls (and mark the machine incomplete where appropriate), with a fixture for private and package-private methods. Focused TestStateHints passed, so this case is currently untested.

CatarinaGamboa added a commit that referenced this pull request Nov 3, 2026
…unknown (#458)

Part of #454 (part (a): what the hint suggests when any state is
possible, and how hints name states). Part (b), suggesting a check such
as `canRedo()`, needs return-value queries modelled as guards. That is a
separate change.

## Problem

When the receiver's state is unknown, the hint has two problems:

1. It suggests transitions whose own precondition is just as unknown. In
the issue, `redo()` needs `redoable`, and the hint suggests `undo()`,
which needs `undoable`. Following it only moves the error to `undo()`.
2. It names states in every combination with state sets the call does
not check: "undoable and redoable or notUndoable and redoable", and six
combinations with the study's three-set spec. Hints for partly known
states do the same ("No sequence of calls moves this from <6
combinations> to <6 combinations>").

Before (the issue's JabRef exercise in VS Code):


![before](https://raw.githubusercontent.com/liquid-java/liquidjava/pr-screenshots/issues/454/verification.png)

## Cause

`StateHints.reachingCalls` listed every transition from a state that
does not allow the call into one that does
(`StateMachine.transitionsEnabling`), whatever its `from`. All hints
printed a set of states by joining each state's names in every state
set.

## Fix

- **States are named briefly, everywhere.** `StateMachine.describe` (new
`StateCover`) names a set of states as the fewest groups that each fix
the state in some state sets and allow any state in the others: a
smallest cover by maximal sub-products of the state sets. It works on
the machine's solver-built states, with no string matching. Every state
hint uses it, for the current, needed and reached states. For example:
  - `redoable(this)` with three state sets reads `redoable`;
  - `closed(this) && unlocked(this)` reads `closed and unlocked`;
  - `open(this) || loud(this)` across two sets reads `open or loud`;
- two refinements `from = "undoable(this)"` and `from =
"redoable(this)"` read `undoable or redoable`;
  - a receiver known only to be `notRedoable` reads `notRedoable`.

The receiver's current state is named in every state set the call
checks, plus any set its states agree on. So a join that differs in a
checked set still lists each state (`closed and unlocked or opened and
unlocked`), and one that differs only in an unchecked set does not
(`closed`). A single fully known state reads as before (`closed and
locked`). When every state allows the call, each by its own refinement,
the needed state is named per refinement (`a or b`, not every state).
- **Only calls that work from any state.** When any state is possible,
the hint reuses the call sequence search of the known-state case,
starting from every state. A call is suggested only if it is allowed in
every state. That covers transitions with no `from` and sequences that
start with one (`reset(), then open()`). Same limits as before: at most
3 sequences of at most 3 calls.
- **The needed state is always named.** The `NEEDED_STATE` hint (renamed
from `REACHING_CALLS`, which nothing in vscode-liquidjava or
liquidjava-mcp uses) always names the state the call needs, plus the
constructors that create an object in it. That includes a call that
every state allows, each by a different refinement (a toggle).
Constructors are not suggested when the receiver is `this`. The same
applies to the existing "Create a new object instead" hint.
- **Only constructors that can be called.** `StateMachineBuilder` leaves
out the constructors of abstract classes and interfaces (`abstractType`,
as in #464), enums, and private constructors, so no hint suggests `new`
for them.
- **A failed check is not "unknown".** In `if (!it.hasNext())
it.next();`, the receiver's own refinement says nothing, but the error's
found (`(#fresh == true --> hasMore(it)) && !#fresh`) mentions its state
through the check's result. `StateErrorCategories.checkedElsewhere`
looks at what the error shows as found (`TypeChecker.shownFound`, the
same premise chain the error prints). It only computes that when every
state is possible and the receiver's own refinement mentions none. It
counts a state only if it is applied to the receiver's instance, or to
an object the solver proves equal to it (`Scanner same = it; if
(!it.hasNext()) same.next();`). A check on another object (`if
(other.hasNext()) it.next();`, or the "checked the wrong object" `if
(it.hasNext()) other.next();`) leaves the state unknown. When it does
count, the category is none instead of UNKNOWN, and the hint reads "What
is known here does not decide the state of it, and this call needs it to
be hasMore". Joins of every state across several state sets (`if (p)
d.open(); if (q) d.lock(); d.enter();`) are not checks. They keep main's
category (none) and main's "No sequence of calls moves d from … Create a
new object instead" hint.
- **What changes outside the unknown case:**
- The only category change is UNKNOWN to none for a failed check on the
receiver itself (`iterator_observer_error`, `state_test_method_error`).
- Known-state hints change in two ways. States are named more briefly,
as above. The constructor suggestions also go away for `this` receivers
and for abstract classes, interfaces, enums and private constructors (in
"Create a new object instead with …").
- `StateMachineBuilder` now leaves those constructors out of the
machine. `canModel` and the methods it admits are unchanged.

**Extension point for part (b).** `StateHints.anyState` builds the hints
for "any state is possible": the needed state, then the call sequences
from any state. It already tells the two causes apart: nothing known, or
a check that did not decide the state. A guard hint goes between the two
hints, once the machine records queries whose return value decides a
state set. For example: "Check `undoManager.canRedo()` before this
call", or for a failed check, "Call `it.next()` only when `it.hasNext()`
returns true".

**Clean-code pass** (878738a, no change in any hint: all 84 state
fixtures and 30 reviewer reproducers print the same hints before and
after):
- `StateCover` searches for the smallest cover exactly, with no greedy
fallback, and keeps each group's states with it.
- `StateHints` takes the needed states from one source, and names the
checked state sets (`checkedSets`) and the check flag
(`checkedElsewhere`) apart.
- `StateErrorCategories` shares its "every state possible, none
mentioned" test.
- `StateMachine.statesAllowing` is derived from
`statesAllowingByRefinement`.
- `AuxStateHandler` only looks for a check when no argument decided the
state.

## Hint output, before (main) and after

**Issue reproducer** (external `UndoManager` spec, two state sets,
receiver from a method return):

```
before: The state of undoManager is unknown here. To get it to undoable and redoable or notUndoable and redoable, use: undoManager.undo() (from undoable and notRedoable)
after:  The state of undoManager is unknown here, and this call needs it to be redoable
```

**Three state sets** (parameter receiver):

```
before: The state of history is unknown here. To get it to start and undoable and redoable or start and notUndoable and redoable or canEdit and undoable and redoable or canEdit and notUndoable and redoable or end and undoable and redoable or end and notUndoable and redoable, use: history.undo() (from start and undoable and notRedoable or canEdit and undoable and notRedoable or end and undoable and notRedoable)
after:  The state of history is unknown here, and this call needs it to be redoable
```

**Disjunction across state sets** (`from = "open(this) || loud(this)"`)
and **one refinement per set** (`undoOrRedo()`):

```
before: No state information is known for this object. Make sure its state is initialized and available at this point.
after:  The state of s is unknown here, and this call needs it to be open or loud
after:  The state of history is unknown here, and this call needs it to be undoable or redoable
```

**A failed check** (`if (!it.hasNext()) it.next();`):

```
before: (no hint)
after:  What is known here does not decide the state of it, and this call needs it to be hasMore
```

**A call allowed in any state** (`reset()` has no `from`; `open()` needs
`created`):

```
before: The state of f is unknown here. To get it to open, use: f.open() (from created)
after:  The state of f is unknown here, and this call needs it to be open
        Call f.reset(), then f.open() before this call to move f from any state to open
```

**Partly known state** (`this` known to be `notRedoable`, three state
sets):

```
before: No sequence of calls moves this from start and undoable and notRedoable or start and notUndoable and notRedoable or canEdit and undoable and notRedoable or canEdit and notUndoable and notRedoable or end and undoable and notRedoable or end and notUndoable and notRedoable to start and undoable and redoable or ... (6 combinations)
        In state start and undoable and notRedoable or ... (6 combinations), this can call: check()
after:  No sequence of calls moves this from notRedoable to redoable
        In state notRedoable, this can call: check()
```

**Toggle** (a class whose `flip()` goes from `up` to `down` and back):

```
before: The state of t is unknown here. To get it to up or down, use: new ErrorStateHintUnknownToggle()
after:  The state of t is unknown here, and this call needs it to be up or down. Objects created with new ErrorStateHintUnknownToggle() start in such a state
```

**Known state, unchanged** (after `open(); close();`):

```
Call f.reset(), then f.open() before this call to move f from closed to open
In state closed, f can call: reset()
```

**Every other state fixture.** I ran each `Error*` state fixture and
test-suite directory on main and on this branch and compared the hints.
Apart from the cases above, the only changes are:
- every "unknown" hint now has the new wording;
- `iterator_observer_error` and `resultset_forward_error` (line 67) now
get the needed-state hint. On main they got the generic "No state
information is known…" hint, or no hint after a failed `hasNext()`;
- `state_test_method_error` (`if (edit.canUndo()) {} else edit.undo();`)
now reads "What is known here does not decide the state of edit…"
instead of "unknown", and no longer suggests `edit.redo()`;
- `ErrorLoopPostState` no longer suggests `new ErrorLoopPostState()` for
`this`;
- `ErrorPathDependentOtherStateSet` now reads "from closed to opened"
instead of two combinations each.

**Merging with #464.** Both PRs touch `StateMachineBuilder` and
`StateHints`, and the conflicts are mechanical:
- keep one `abstractType`: both PRs add an identical one;
- move #464's `.distinct()` on the constructor lists into
`StateHints.newObjects`, which replaces both lists here;
- merge this PR's `typeName` (in `privateConstructor`) with #464's
`parameterTypes`, which do the same thing.

## Tests

Each test pins one behaviour. To keep CI fast, the fixtures are grouped
so that one verifier run checks several errors. Every new test fails
without its fix: on the PR's base, or on the commit before the fix it
pins.

| Test (`TestStateHints`) | Fixture | Pins |
|---|---|---|
| `unknownStateOfAnExternalClassNamesOnlyTheCheckedStateSet` |
`classes/state_hint_undo_manager_error` | the issue reproducer: needs
`redoable`, `undo()` not suggested |
| `unknownStateDoesNotSuggestACallThatNeedsAnotherUnknownState` |
`ErrorStateHintUnknownState` (existing, expectation changed) | a call
that needs another unknown state is not suggested |
| `unknownStateSuggestsCallsAllowedInAnyState` |
`ErrorStateHintUnknownAnyState` | `reset(), then open()` is suggested;
`open()` alone is not |
| `statesAreNamedOnlyInTheStateSetsThatMatter` | `ErrorStateHintNaming`
(3 errors) | `open or loud` across sets; a toggle on `{a, b} × {x, y,
z}` reads `a or b`; a partly known state reads `notRedoable` |
| `failedCheckOfTheReceiverIsNotAnUnknownState` |
`classes/state_hint_checked_error` (3 errors) | a failed `hasNext()` on
the receiver, or on a variable holding it, "does not decide", with no
category; a check of another scanner leaves it unknown (UNKNOWN) |
| `joinsAreNotTakenForAnUnknownState` | `ErrorStateHintJoins` (2 errors)
| a join of every state of two sets keeps main's "No sequence of calls"
hint, with no category; a join in one checked set lists both states |
| `onlyConstructorsThatCanBeCalledAreSuggested` |
`ErrorStateHintConstructors` (4 errors) | a concrete class's constructor
is suggested; none for `this`, an abstract class or a private
constructor |

`StateMachineTest` has 10 unit tests for `describe` and
`stateSetsNamed`. They run in milliseconds and cover the state naming:
one set, cross-set and three-set disjunctions, every state, forced sets,
and declaration order.

Removed as overlapping: separate fixtures for three state sets, a
one-set disjunction, one refinement per set, a two-set goal, a field
receiver, several and long sequences, the one-set toggle, a known state,
and the external abstract and interface specs. Each is covered by one of
the tests above, by an existing test or by the unit tests. The interface
spec never reached the guard, since an interface cannot declare a
constructor refinement.

**Test time** (`mvn test` for the verifier, two interleaved runs of each
on the same machine, surefire time summed over test cases):

| | tests | surefire | wall | `TestExamples` | `TestStateHints` |
|---|---|---|---|---|---|
| base (0053074) | 547 | 81.9 s | 89.1 s | 64.8 s | 2.2 s |
| PR before trimming (b9683e9) | 613 | 98.6 s (+16.7) | 106.0 s (+16.9)
| 72.2 s | 9.7 s |
| PR now | 569 | 88.2 s (+6.3) | 95.2 s (+6.1) | 67.3 s | 5.3 s |

`mvn test` for the verifier: **569 tests, 0 failures, 0 errors**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CatarinaGamboa and others added 4 commits November 3, 2026 20:10
…'s supertypes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-named classes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A private method is not inherited, nor is a package-private one by a class in
another package, so suggesting it gives a call that does not compile. Such
methods leave the machine incomplete instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa
CatarinaGamboa force-pushed the fix/456-inherited-state-hints branch from fc7acd4 to 86e7ded Compare November 3, 2026 20:19
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Rebased onto main, which now has #458, #459 and #460, and addressed the Codex review.

Rebase. The conflicts in AuxStateHandler and StateMachineBuilder were with #458:

  • The checked-state test and the category now use the receiver class's ghost states, including inherited ones, as categorize already did on this branch.
  • statesOf(Context, String, boolean) stays public for LoopTypestates. It delegates to the list-based version, which groups state sets by qualified name and supports withNone.
  • The own-class constructor branch keeps Suggest only calls allowed in any state when the receiver's state is unknown #458's abstract and private constructor exclusions.
  • There is now one abstractType. parameterTypes reuses typeName, and newObjects keeps this PR's .distinct().

Codex: inaccessible inherited methods (86e7ded). An inherited method now joins the receiver's machine only if the receiver's class inherits it. A private method never counts, and a package-private one counts only when the subclass is in the same package. Any other method is left out and marks the machine incomplete, so the hints don't claim that no sequence exists. Interface methods always count.

New fixture: classes/state_hints_inaccessible_inherited_error. Hatch has a private open() and a package-private openInPackage(). A subclass in the same package is told to call openInPackage() only ("and possibly others"). A subclass in another package gets no hints. Without the fix, both errors suggested hatch.open(), plus openInPackage() in the other package.

The full suite passes locally (mvn test, 0 failures).

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex review] Protected inherited methods are still suggested from unrelated callers. Example: testSuite.lib.Parent declares protected open() and testSuite.Child extends Parent inherits it; testSuite.Use calls child.use() where use() requires the state open() establishes. The hint can suggest child.open(), which does not compile from Use. Please account for caller access (or omit protected methods when it cannot be established).

The machine is shared by every caller, so a protected or package-private
method is left out as well: some callers cannot call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Codex is right, and the package-private rule from 86e7ded had the same hole. Whether such a call compiles depends on where the caller is, not on the subclass's package. The machine is built once per class and shared by every caller (TypeChecker.stateMachines), so it can't check access per call.

Fixed in 2f97168: an inherited method joins the receiver's machine only if it is public, or declared in an interface. Private, package-private and protected methods are left out, and the machine is marked incomplete, so the hints say "(and possibly others)" and never claim that no sequence exists. A caller that could reach such a method loses that suggestion, but every suggested call now compiles from any caller.

The fixture Hatch now has a private, a package-private, a protected and a public way to open. A same-package subclass is told to call only openPublicly(). The full suite passes locally.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex re-review] The public-only filter still admits private interface methods: publiclyInherited returns true for every interface at line 299. A private @StateRefinement helper on an interface is not inherited by an implementing class, so the hint can suggest child.open() when it cannot compile. Please check method visibility for interfaces too, while retaining public interface methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Fixed in 343c7f6. Interface methods are no longer accepted wholesale: a method declared in an interface counts unless it is private (the others are implicitly public), and a method declared in a class must be public.

New case in the same fixture, Door: an interface with a private pry() and a default push(), both from shut to ajar. Its implementing class FrontDoor is told to call only door.push() ("and possibly others"). On the previous commit the hints suggested door.pry() too. The test compares the two errors' hints as a set, because the fixture's files are verified in no fixed order. The full suite passes locally.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex re-review] The latest update filters private interface methods and adds a fixture covering the case. I found no further issue in this change; CI is green.

…by name and arity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Ran /clean-code-principles on this PR. Applied in 20e7a45 (no change in behaviour; the full suite passed on the change before a final rename-only tidy, and TestStateHints and TestErrorCategories pass after it):

  • AuxStateHandler: [DRY / efficiency] ghostStatesOf walked the receiver's supertypes twice per error, once for the category and once for the checked test. ReceiverStates now holds the ghost states instead of the class name. This also removes a ternary that the formatter had wrapped into six lines.
  • StateMachineBuilder.build: [KISS] className.equals(function.getTargetClass()) was tested three times. It is now one own flag, with a single applies decision for own and inherited methods.
  • StateMachineBuilder: [DRY] resolvesTo and publiclyInherited repeated the lookup of a method by name and number of parameters. It is now declarationsOf.

Skipped:

  • Stream.concat(Stream.of(type), supertypes.stream()) appears in both ghostStatesOf and resolvesTo. A helper would save one line each.
  • AuxHierarchyRefinementsPassage passes the method instead of the parameter as the placement element. This came from the original commit and I left it as is.

Tests: the 9 new TestStateHints cases take 0.25–0.6 s each, and each one pins a separate review finding, so I kept them all.

@CatarinaGamboa
CatarinaGamboa merged commit dc1a63f into main Nov 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No state hints when the state set is declared in a superclass's external spec (Window) and the method in a subclass's (Frame)

1 participant