Skip to content

Apply external class specs to user subclasses - #354

Merged
CatarinaGamboa merged 3 commits into
mainfrom
fix/16-subclass-receivers
Oct 8, 2026
Merged

CatarinaGamboa merged 3 commits into
mainfrom
fix/16-subclass-receivers

Conversation

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator

Fixes #353.

Problem

An external spec (@ExternalRefinementsFor("java.lang.Throwable")) did not reach user subclasses: calling initCause on a custom RuntimeException subclass crashed with Sort mismatch at argument #1 for function java.lang.Throwable.state1 … supplied sort is AppException.

Example

class AppException extends RuntimeException {
    AppException(String message, Throwable cause) { super(message, cause); }
}
AppException e = new AppException("operation failed", underlying);
e.initCause(detail); // now: State Refinement Error, found withThrowable(e) but expected noThrowable(e)

With super(message) instead, the same initCause passes.

Change

  • RefinedVariable.addSuperTypes walks superclasses and interfaces transitively.
  • AuxStateHandler: a constructor whose first statement is super(...) inherits the to-state of the super constructor's spec, with the super arguments renamed to this constructor's parameters (or literals). If a to-state uses an argument that cannot be named, it falls back to the default state as before. The spec lookup walks up only through Exception, RuntimeException and Error, whose constructors delegate unchanged to Throwable; other JDK subclasses are not assumed to (e.g. ClassNotFoundException(String) sets a null cause).
  • RefinementProcessor: the first pass is split so all external specs are registered before any user class is checked; before, the result depended on the order of types in the package.

Tests

  • testSuite/classes/throwable_subclass_error: subclass built with super(message, cause), then initCause, gives a State Refinement Error.
  • testSuite/classes/throwable_subclass_correct: subclass built with super(message), then initCause, passes.
  • mvn test passes.

🤖 Generated with Claude Code

Fixes #353. A typestate call on a subclass of a specified class (e.g. a
custom RuntimeException calling initCause) crashed with a Z3 sort mismatch.

- RefinedVariable records supertypes transitively, so a subclass is related
  to the specified class however far up it is.
- A constructor whose first statement is super(...) takes the state the super
  constructor's spec gives, with the super arguments renamed to this
  constructor's parameters. The lookup walks through Exception,
  RuntimeException and Error only, whose constructors delegate to Throwable's
  unchanged; other JDK classes may not (ClassNotFoundException(String) sets a
  null cause), so they are not assumed to.
- RefinementProcessor registers all external specs before checking user
  classes, so the order of types in the package no longer matters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
return null;
}

private static boolean inheritSuperConstructorState(CtConstructor<?> c, RefinedFunction f, TypeChecker tc) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a huge method can we maybe split it? and maybe add some documentation - at least 1 line what is doing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split in 2c1cfbc: inheritSuperConstructorState is now about 15 lines and calls superConstructorCall (find the leading super(...)), specifiedConstructor (find the spec, walking through the delegating JDK classes), superParamsToArguments (map super params to the argument names) and renameStates (substitute them into the to-states). Each one has a Javadoc line. The method's own Javadoc had also ended up above the DELEGATING_JDK_CLASSES constant. I moved it back onto the method.

@CatarinaGamboa CatarinaGamboa added the enhancement New feature or request label Oct 7, 2026
- Log unresolvable supertypes in debug mode (new DebugLog.warn)
- Walk supertypes in a single loop
- Split inheritSuperConstructorState into documented helpers and fix
  its misplaced Javadoc

Co-Authored-By: Claude Opus 5.5 <[email protected]>
* The given states with the super constructor's parameters renamed, or null if one of them mentions a parameter
* whose argument has no name.
*/
private static List<ObjectState> renameStates(List<Predicate> toStates, Map<String, String> rename) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are a lot of new functions in here, do we need them all? could we make them more concise? you can use more of a functional style if they end up more concise

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut down in 7aa1bb0: there are now 3 functions instead of 5, and the code is about 30 lines shorter.

  • superConstructorCall is inlined into inheritSuperConstructorState as a pattern-matching guard.
  • superParamsToArguments and renameStates are merged into the main method. It builds a rename map and an unnamed set, rejects the inheritance if a state mentions an unnamed parameter (anyMatch), then maps each to-state through the substitutions with a reduce.
  • argumentName is small, returns an Optional, and says how a single argument can be named.
  • specifiedConstructor takes the CtExecutableReference directly instead of its three parts.

I kept argumentName and specifiedConstructor separate: the first is the per-argument rule and the second is the JDK delegation walk, and inlining them would make the main method harder to read.

@CatarinaGamboa
CatarinaGamboa merged commit 2e8d227 into main Oct 8, 2026
1 check passed
@rcosta358
rcosta358 deleted the fix/16-subclass-receivers branch October 13, 2026 16:56
CatarinaGamboa added a commit that referenced this pull request Oct 29, 2026
#433)

Closes #367.

## Problem
A JDK exception created directly gets no state from the `Throwable`
spec, so a correct `initCause` is reported:
```java
RuntimeException e = new RuntimeException("failed");
e.initCause(t);   // State Refinement Error: found true but expected noThrowable(e)
```
`new IllegalStateException("failed")` gives the same error. The real bug
in this pattern, in scijava-common's `CommandInfo` (fixed in a6fd9cf),
was reported, but only because the state was unknown. The actual cause
is that `ClassNotFoundException()` already sets a (null) cause:
```java
final ClassNotFoundException cnfe = new ClassNotFoundException();
cnfe.initCause(exc);   // IllegalStateException: Can't overwrite cause
```

## Cause
A constructor with no spec of its own gets the default state. #354 lets
a lookup walk up to an ancestor's spec, but only:
- through a hard-coded list (`Exception`, `RuntimeException`, `Error`);
- for `super(...)` in user constructors;
- when the ancestor constructor has the same parameter types.

It also looked only at the first statement. So `super(m); initCause(c);`
in a `RuntimeException` subclass inherited `Throwable(String)`'s state,
and a second `initCause` was missed.

## Fix
- **`ConstructorDelegation`** reads a JDK class with ASM. It accepts a
constructor only when its whole body has this shape:
  ```
  aload_0
  (load of a parameter | constant [checkcast after null])*
  invokespecial <direct superclass>.<init>
  return
  ```
Any other instruction rejects the constructor: a field write, a method
call, `this(...)`, or a branch. For each superclass parameter it records
what is passed: one of the constructor's own parameters (any order or
subset), or a constant. Int constants passed for a boolean, int, short
or byte parameter keep their value. Other constants (`null`, strings,
long/float/double, chars) have no known value, in bytecode and in source
alike. Results are cached per constructor.
- **`AuxStateHandler`** replaces `DELEGATING_JDK_CLASSES` with a walk
through delegating constructors:
  - The arguments are mapped at each step.
- Each ancestor's spec is looked up by exact parameter types, so
`getFunction`'s fallback to another overload with the same number of
parameters is not used.
- The spec's parameters are replaced by the values passed: a variable's
current instance, or a literal for boolean and int constants. A later
assignment to the variable does not change the state. If a `to` state
mentions a parameter whose value is not known (for example, a parameter
that has no instance yet), the walk is refused.
- A spec whose `to` mentions `old(this)` is skipped. That happens when a
constructor spec leaves another state set unchanged. Calling such a
constructor directly already fails on main with `Ghost 'old' could not
be found`; that pre-existing bug is left for a separate issue.
- State sets of the constructed class that the ancestor's `to` does not
mention keep their default value. For example, `Stack()` runs
`Vector()`, and Stack's own state set still starts empty.
- **Where it applies:** to `new X(...)` when `X` has no spec of its own
(except anonymous classes), and to a user constructor whose body is only
`super(...)`, with no instance or field initializers. That is the
source-level version of the bytecode rule. Anything else gets the
default state, as before.

`ClassNotFoundException()` is `aload_0; aconst_null; checkcast
Throwable; invokespecial ReflectiveOperationException(Throwable)`, so it
reaches `Throwable(Throwable)` and the object is `withThrowable`.

Not covered:
- **Only JDK classes are read**, through the platform class loader. The
verifier runs without the program's classpath, and its own libraries may
be different versions from the ones the program uses. Third-party
intermediate classes stop the walk.
- **The result depends on the JDK the verifier runs on**, which may not
be the one the program targets. If that JDK changes a constructor body,
or its class files are newer than ASM 9.11 can read, the walk stops
without a warning and the object gets the default state, as on main.
- **Not walked:** `this(...)` chains inside a binary class, and
constructors that do anything besides delegate.
- **Parameter refinements:** a walked spec's parameter refinements are
not checked at the `new` site. Only its state is applied, and nothing
was checked there before.
- **State-change hint:** on the delegated path the hint metadata that
names the call which set the state is not recorded, so state hints
cannot point at that call.
- **Overridden methods:** methods a subclass overrides and an ancestor's
constructor calls (e.g. `fillInStackTrace`) are not considered, as for
any spec. So a subclass of a newly walked class such as `IOException`,
whose override sets the cause, is now missed. Main reported it only
because the state was unknown. A call to `initCause` inside the override
is still reported.
- **Type variables:** the ancestor lookup compares exact type names, so
a spec parameter written as a type variable does not match the erased
bytecode type. The walk then stops and the state falls back to the
default.

## Tests
- **`classes/throwable_jdk_subclass_correct`:** `new
RuntimeException(msg)`, `new IllegalStateException(msg)` and `new
Exception()` followed by `initCause`, and an `IOException` subclass
calling `super(msg)`. Main reports 3 false errors.
- **`classes/throwable_jdk_subclass_error`:** `new
ClassNotFoundException()`, `new RuntimeException(msg, cause)` and a
`ClassNotFoundException` subclass calling `super(String)`, each followed
by `initCause`. It also has `MissingResourceException`, which is not
delegating and so gets the default state.
`TestDelegatedConstructorStates` checks that the state found on the
first three is `withThrowable`; main reports the same lines with an
unknown state.
- **`classes/throwable_subclass_more_than_super_error`:** `super(m);
initCause(c);`, an implicit `super()` followed by `initCause`, and an
instance initializer that sets the cause. Main misses the first and
third.
- **`classes/delegated_constant_arguments_error`:** `LinkedHashSet()`
reaches `HashSet(16, .75f, true)`, and `16` and `true` reach the spec as
literals.
- **`classes/delegated_subclass_sets_error`:** `Stack` keeps its own
default state after `Vector()`.
- **`classes/delegated_argument_reassigned_error`:** a variable passed
to `new` and assigned before or after the call, and a lambda parameter.
- **`classes/delegated_spec_keeping_other_sets_correct`:** a delegated
call to a constructor spec that mentions `old(this)` gets the default
state, without an error.
- **`classes/super_swapped_arguments_correct` / `_error`:** `Sub(boolean
y, boolean x) { super(y, x); }` for `Base(boolean x, boolean y)`, whose
state mentions both parameters. They are replaced at once; replacing one
after the other turned both into `x` (also wrong on main).
- **`ConstructorDelegationTest`:** unit tests for the bytecode matcher.
- **`mvn test`:** 532 tests, 0 failures (rebased on main at 92963a7).

## Real-file results
- **scijava-common `CommandInfo.java` (d9dce68)** with the Throwable
spec:
  - Main: `found !#fresh but expected noThrowable(cnfe)`.
- With this PR: `found !#fresh && withThrowable(cnfe) but expected
noThrowable(cnfe)` at line 304.
- The fixed version (`throw new ClassNotFoundException(null, exc)`)
passes on both.
- **The 53 OSS exercises (74 files, buggy and fixed)**, on main
(4a89df6) and with this PR: identical results.

## Dependency
`org.ow2.asm:asm` 9.11, licence BSD-3-Clause, a single jar with no
transitive dependencies. Spoon 11.5.0 does not bring ASM in.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

External specs do not reach user subclasses: a typestate call on a subclass receiver crashes with a sort mismatch

1 participant