From 05f48177ce3b26a71cd51c5f400a473fb2268471 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 30 Jan 2026 11:28:38 -0500 Subject: [PATCH 1/3] feat: add attribution to violation handling for better error reporting --- .../nullness/NullnessRuntimeVerifier.java | 14 ++++++++++- .../runtime/LoggingViolationHandler.java | 23 ++++++++++++++++-- .../runtime/RuntimeVerifier.java | 8 ++++++- .../runtime/ThrowingViolationHandler.java | 24 +++++++++++++++++-- .../runtime/ViolationHandler.java | 13 +++++++++- .../eisop/testutils/TestViolationHandler.java | 8 ++++--- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java index 05062de..0c39ff7 100644 --- a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.checker.nullness; +import io.github.eisop.runtimeframework.runtime.AttributionKind; import io.github.eisop.runtimeframework.runtime.RuntimeVerifier; /** @@ -18,8 +19,19 @@ public class NullnessRuntimeVerifier extends RuntimeVerifier { * @param message The error message to report if the object is null */ public static void checkNotNull(Object o, String message) { + checkNotNull(o, message, AttributionKind.LOCAL); + } + + /** + * Verifies that the given object is not null, with specific attribution. + * + * @param o The object to check + * @param message The error message to report if the object is null + * @param attribution The attribution strategy + */ + public static void checkNotNull(Object o, String message, AttributionKind attribution) { if (o == null) { - reportViolation("Nullness", message); + reportViolation("Nullness", message, attribution); } } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java index df4fec8..97bf554 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java @@ -19,7 +19,26 @@ public LoggingViolationHandler(PrintStream out) { } @Override - public void handleViolation(String checkerName, String message) { - out.printf("[RuntimeFramework - %s] %s%n", checkerName, message); + public void handleViolation(String checkerName, String message, AttributionKind attribution) { + StackTraceElement source = findSource(attribution); + String location = + (source != null) ? source.getFileName() + ":" + source.getLineNumber() : "Unknown:0"; + + out.printf("[RuntimeFramework - %s] (%s) %s%n", checkerName, location, message); + } + + private StackTraceElement findSource(AttributionKind attribution) { + return StackWalker.getInstance() + .walk( + stream -> + stream + // Skip the runtime framework infrastructure + .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) + // Skip the method that triggered the violation if we are attributing to the + // CALLER + .skip(attribution == AttributionKind.CALLER ? 1 : 0) + .findFirst() + .map(StackWalker.StackFrame::toStackTraceElement) + .orElse(null)); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java index 77912c0..124be27 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java @@ -44,6 +44,12 @@ public static void setViolationHandler(ViolationHandler newHandler) { /** Reports a violation to the current handler. */ protected static void reportViolation(String checkerName, String message) { - handler.handleViolation(checkerName, message); + reportViolation(checkerName, message, AttributionKind.LOCAL); + } + + /** Reports a violation to the current handler with specific attribution. */ + protected static void reportViolation( + String checkerName, String message, AttributionKind attribution) { + handler.handleViolation(checkerName, message, attribution); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java index 9ae0600..5b545f5 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java @@ -4,7 +4,27 @@ public class ThrowingViolationHandler implements ViolationHandler { @Override - public void handleViolation(String checkerName, String message) { - throw new RuntimeException(String.format("[%s Violation] %s", checkerName, message)); + public void handleViolation(String checkerName, String message, AttributionKind attribution) { + StackTraceElement source = findSource(attribution); + String location = + (source != null) ? source.getFileName() + ":" + source.getLineNumber() : "Unknown:0"; + + throw new RuntimeException( + String.format("[%s Violation] (%s) %s", checkerName, location, message)); + } + + private StackTraceElement findSource(AttributionKind attribution) { + return StackWalker.getInstance() + .walk( + stream -> + stream + // Skip the runtime framework infrastructure + .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) + // Skip the method that triggered the violation if we are attributing to the + // CALLER + .skip(attribution == AttributionKind.CALLER ? 1 : 0) + .findFirst() + .map(StackWalker.StackFrame::toStackTraceElement) + .orElse(null)); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java index 7f2e957..77eb022 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java @@ -9,5 +9,16 @@ public interface ViolationHandler { * @param checkerName The name of the checker that detected the violation * @param message The descriptive error message provided by the verification logic */ - void handleViolation(String checkerName, String message); + default void handleViolation(String checkerName, String message) { + handleViolation(checkerName, message, AttributionKind.LOCAL); + } + + /** + * Handle a reported violation with specific attribution logic. + * + * @param checkerName The name of the checker that detected the violation + * @param message The descriptive error message provided by the verification logic + * @param attribution The strategy for determining the source of the error + */ + void handleViolation(String checkerName, String message, AttributionKind attribution); } diff --git a/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java index 9382c6c..4c320ab 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java @@ -1,5 +1,6 @@ package io.github.eisop.testutils; +import io.github.eisop.runtimeframework.runtime.AttributionKind; import io.github.eisop.runtimeframework.runtime.ViolationHandler; /** @@ -9,8 +10,8 @@ public class TestViolationHandler implements ViolationHandler { @Override - public void handleViolation(String checkerName, String message) { - StackTraceElement caller = findCaller(); + public void handleViolation(String checkerName, String message, AttributionKind attribution) { + StackTraceElement caller = findCaller(attribution); String location = (caller != null) ? caller.getFileName() + ":" + caller.getLineNumber() : "Unknown:0"; @@ -18,13 +19,14 @@ public void handleViolation(String checkerName, String message) { System.out.println(output); } - private StackTraceElement findCaller() { + private StackTraceElement findCaller(AttributionKind attribution) { return StackWalker.getInstance() .walk( stream -> stream .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) .filter(f -> !f.getClassName().startsWith("io.github.eisop.testutils")) + .skip(attribution == AttributionKind.CALLER ? 1 : 0) .findFirst() .map(StackWalker.StackFrame::toStackTraceElement) .orElse(null)); From 7e91d43bf55194488f8bf2f87cbdd851a5a13643 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 30 Jan 2026 11:57:46 -0500 Subject: [PATCH 2/3] feat: add attributions to verifiers and policies --- .../checker/nullness/NullnessVerifier.java | 40 +++++++++++++++++-- .../core/RuntimeVerifier.java | 11 +++++ .../policy/StandardEnforcementPolicy.java | 9 +++-- .../runtime/AttributionKind.java | 19 +++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/runtime/AttributionKind.java diff --git a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessVerifier.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessVerifier.java index 67a0f01..4db17bf 100644 --- a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessVerifier.java +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessVerifier.java @@ -1,6 +1,7 @@ package io.github.eisop.runtimeframework.checker.nullness; import io.github.eisop.runtimeframework.core.RuntimeVerifier; +import io.github.eisop.runtimeframework.runtime.AttributionKind; import java.lang.classfile.CodeBuilder; import java.lang.classfile.TypeKind; import java.lang.constant.ClassDesc; @@ -9,15 +10,48 @@ public class NullnessVerifier implements RuntimeVerifier { private static final ClassDesc VERIFIER = ClassDesc.of(NullnessRuntimeVerifier.class.getName()); - private static final String METHOD = "checkNotNull"; - private static final MethodTypeDesc DESC = + private static final ClassDesc ATTRIBUTION_KIND = + ClassDesc.of(AttributionKind.class.getName()); + + private static final String METHOD_DEFAULT = "checkNotNull"; + private static final MethodTypeDesc DESC_DEFAULT = MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/String;)V"); + private static final String METHOD_ATTRIBUTED = "checkNotNull"; + private static final MethodTypeDesc DESC_ATTRIBUTED = + MethodTypeDesc.ofDescriptor( + "(Ljava/lang/Object;Ljava/lang/String;Lio/github/eisop/runtimeframework/runtime/AttributionKind;)V"); + + private final AttributionKind attribution; + + public NullnessVerifier() { + this(AttributionKind.LOCAL); + } + + public NullnessVerifier(AttributionKind attribution) { + this.attribution = attribution; + } + + @Override + public RuntimeVerifier withAttribution(AttributionKind kind) { + if (this.attribution == kind) return this; + return new NullnessVerifier(kind); + } + @Override public void generateCheck(CodeBuilder b, TypeKind type, String diagnosticName) { if (type == TypeKind.REFERENCE) { b.ldc(diagnosticName + " must be NonNull"); - b.invokestatic(VERIFIER, METHOD, DESC); + + if (attribution == AttributionKind.LOCAL) { + b.invokestatic(VERIFIER, METHOD_DEFAULT, DESC_DEFAULT); + } else { + b.getstatic( + ATTRIBUTION_KIND, + attribution.name(), + ClassDesc.ofDescriptor("Lio/github/eisop/runtimeframework/runtime/AttributionKind;")); + b.invokestatic(VERIFIER, METHOD_ATTRIBUTED, DESC_ATTRIBUTED); + } } else { if (type.slotSize() == 1) b.pop(); else b.pop2(); diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeVerifier.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeVerifier.java index 5b1320d..a82c6fb 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeVerifier.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeVerifier.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.core; +import io.github.eisop.runtimeframework.runtime.AttributionKind; import java.lang.classfile.CodeBuilder; import java.lang.classfile.TypeKind; @@ -25,4 +26,14 @@ public interface RuntimeVerifier { * error messages. */ void generateCheck(CodeBuilder b, TypeKind type, String diagnosticName); + + /** + * Returns a verifier that attributes the violation according to the given strategy. + * + * @param kind The attribution strategy. + * @return A verifier with the specified attribution. + */ + default RuntimeVerifier withAttribution(AttributionKind kind) { + return this; + } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java index 5f21c31..35db6d8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -5,6 +5,8 @@ import io.github.eisop.runtimeframework.core.ValidationKind; import io.github.eisop.runtimeframework.filter.ClassInfo; import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.policy.EnforcementPolicy; +import io.github.eisop.runtimeframework.runtime.AttributionKind; import io.github.eisop.runtimeframework.resolution.ParentMethod; import java.lang.classfile.Annotation; import java.lang.classfile.Attributes; @@ -58,7 +60,8 @@ protected RuntimeVerifier resolveVerifier(List annotations) { public RuntimeVerifier getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { if (type != TypeKind.REFERENCE) return null; List annos = getMethodParamAnnotations(method, paramIndex); - return resolveVerifier(annos); + RuntimeVerifier verifier = resolveVerifier(annos); + return (verifier != null) ? verifier.withAttribution(AttributionKind.CALLER) : null; } @Override @@ -178,7 +181,7 @@ public RuntimeVerifier getBridgeParameterCheck(ParentMethod parentMethod, int pa List annos = getMethodParamAnnotations(method, paramIndex); RuntimeVerifier verifier = resolveVerifier(annos); - if (verifier != null) return verifier; + if (verifier != null) return verifier.withAttribution(AttributionKind.CALLER); // Check default var paramTypes = method.methodTypeSymbol().parameterList(); @@ -198,7 +201,7 @@ public RuntimeVerifier getBridgeParameterCheck(ParentMethod parentMethod, int pa if (!isExplicitNoop) { TypeSystemConfiguration.ConfigEntry defaultEntry = configuration.getDefault(); if (defaultEntry != null && defaultEntry.kind() == ValidationKind.ENFORCE) { - return defaultEntry.verifier(); + return defaultEntry.verifier().withAttribution(AttributionKind.CALLER); } } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/AttributionKind.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/AttributionKind.java new file mode 100644 index 0000000..41d6ed0 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/AttributionKind.java @@ -0,0 +1,19 @@ +package io.github.eisop.runtimeframework.runtime; + +/** Defines how a violation should be attributed in the stack trace. */ +public enum AttributionKind { + /** + * The violation occurred in the current method. + * + *

Example: A method returns null but promised @NonNull. The blame is on the method itself. + */ + LOCAL, + + /** + * The violation occurred at the call site of the current method. + * + *

Example: A method received null as an argument but requires @NonNull. The blame is on the + * caller. + */ + CALLER +} From 3d2e62d603792aca0738fb0c4fa7a0859fb4793f Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 30 Jan 2026 11:59:25 -0500 Subject: [PATCH 3/3] test: update test suite to respect new call site attribuition reporting --- .../InheritanceBridgeTest.java | 31 ++++++++++--------- .../UncheckedToCheckedInstance.java | 10 +++--- .../UncheckedToCheckedStatic.java | 6 ++-- .../nullness-parameter/Constructors.java | 7 +++-- .../nullness-parameter/MixedMethods.java | 13 ++++---- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java index 3dd58aa..2414ee8 100644 --- a/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java +++ b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java @@ -1,6 +1,3 @@ -// :: error: (Parameter 0 in inherited method dangerousAction must be NonNull) -// :: error: (Parameter 0 in inherited method protectedAction must be NonNull) - import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import io.github.eisop.runtimeframework.qual.AnnotatedFor; @@ -11,28 +8,34 @@ public class InheritanceBridgeTest extends UncheckedParent { public static void main(String[] args) { InheritanceBridgeTest test = new InheritanceBridgeTest(); test.dangerousAction("safe"); + + // :: error: (Parameter 0 in inherited method dangerousAction must be NonNull) test.dangerousAction(null); - test.overrideMe("safe", "safe"); - test.overrideMe(null, "unsafe"); - test.overrideMe("safe", "null"); + test.overrideMe("safe", "safe"); + + // :: error: (Parameter 0 must be NonNull) + test.overrideMe(null, "unsafe"); + + test.overrideMe("safe", "null"); + + test.protectedAction("safe"); - test.protectedAction("safe"); + // :: error: (Parameter 0 in inherited method protectedAction must be NonNull) test.protectedAction(null); - test.finalAction("safe"); + test.finalAction("safe"); test.finalAction(null); - // cannot bridge final methods, no error here + // cannot bridge final methods, no error here - String unsafe = test.returnAction(); + String unsafe = test.returnAction(); // :: error: (Local Variable Assignment (Slot 2) must be NonNull) - @Nullable String again = test.returnAction(); + @Nullable String again = test.returnAction(); } @Override public void overrideMe(@NonNull String inputA, @Nullable String inputB) { - // :: error: (Parameter 0 must be NonNull) - System.out.println("safe version of this method" + inputA + inputB); + System.out.println("safe version of this method" + inputA + inputB); } -} +} \ No newline at end of file diff --git a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java index 5479ddd..9a139c3 100644 --- a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java +++ b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java @@ -7,21 +7,21 @@ public class UncheckedToCheckedInstance { public void checkedMethod(@NonNull String input) { - // :: error: (Parameter 0 must be NonNull) } public void nullableCheckedMethod(@Nullable String input) { } public void mixedCheckedMethod(@Nullable String input, @NonNull String anotherInput) { - // :: error: (Parameter 1 must be NonNull) } static class UncheckedCaller { public static void invoke(UncheckedToCheckedInstance target) { + // :: error: (Parameter 0 must be NonNull) target.checkedMethod(null); - target.nullableCheckedMethod(null); - target.mixedCheckedMethod(null, null); + target.nullableCheckedMethod(null); + // :: error: (Parameter 1 must be NonNull) + target.mixedCheckedMethod(null, null); } } @@ -29,4 +29,4 @@ public static void main(String[] args) { UncheckedToCheckedInstance target = new UncheckedToCheckedInstance(); UncheckedCaller.invoke(target); } -} +} \ No newline at end of file diff --git a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java index ee0b322..4e87beb 100644 --- a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java +++ b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java @@ -6,20 +6,20 @@ public class UncheckedToCheckedStatic { public static void staticCheckedMethod(@NonNull String input) { - // :: error: (Parameter 0 must be NonNull) } public static void staticCheckedNullableMethod(@Nullable String input) { } public static void staticCheckedMixedMethod(@Nullable String input, @NonNull String anotherInput) { - // :: error: (Parameter 1 must be NonNull) } static class UncheckedCaller { public static void invoke() { + // :: error: (Parameter 0 must be NonNull) UncheckedToCheckedStatic.staticCheckedMethod(null); UncheckedToCheckedStatic.staticCheckedNullableMethod(null); + // :: error: (Parameter 1 must be NonNull) UncheckedToCheckedStatic.staticCheckedMixedMethod(null, null); } } @@ -27,4 +27,4 @@ public static void invoke() { public static void main(String[] args) { UncheckedCaller.invoke(); } -} +} \ No newline at end of file diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java index 2ab2737..c2edaaa 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java @@ -4,15 +4,16 @@ @AnnotatedFor("nullness") public class Constructors { public static void main(String[] args) { + // :: error: (Parameter 0 must be NonNull) new Constructors(null); + + // :: error: (Parameter 0 must be NonNull) new Constructors(null, "ignore"); } public Constructors(String s) { - // :: error: (Parameter 0 must be NonNull) } public Constructors(@NonNull String a, String b) { - // :: error: (Parameter 0 must be NonNull) } -} +} \ No newline at end of file diff --git a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java index 25e9fc4..2685d4d 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java @@ -7,32 +7,31 @@ public class MixedMethods { public static void main(String[] args) { // 1. Explicit NonNull + // :: error: (Parameter 0 must be NonNull) checkExplicit(null); // 2. Implicit NonNull (Strict Default) + // :: error: (Parameter 0 must be NonNull) checkImplicit(null); // 3. Explicit Nullable (Should NOT error) checkNullable(null); - // 3. Explicit Nullable (Should NOT error) + // 4. Multiple Parameters + // :: error: (Parameter 1 must be NonNull) + // :: error: (Parameter 2 must be NonNull) checkMultiple(null,null,null,null); } public static void checkExplicit(@NonNull String s) { - // :: error: (Parameter 0 must be NonNull) } public static void checkImplicit(String s) { - // :: error: (Parameter 0 must be NonNull) } public static void checkNullable(@Nullable String s) { - // No error expected here } public static void checkMultiple(@Nullable String s, String q, String r, @Nullable String v) { - // :: error: (Parameter 1 must be NonNull) - // :: error: (Parameter 2 must be NonNull) } -} +} \ No newline at end of file