From eb680be934be08f7685fe6132473b627c4b8902f Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 28 Jan 2026 16:24:17 +0100 Subject: [PATCH 01/10] add feature config --- .../toolbox/ArchitectureTestBase.java | 278 ++++++++++-------- .../archunit/toolbox/InnertRule.java | 41 +++ 2 files changed, 195 insertions(+), 124 deletions(-) create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java diff --git a/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java b/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java index b8eb625..8e9050f 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java @@ -9,6 +9,9 @@ import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NullMarked; @@ -24,6 +27,25 @@ @Slf4j @NullMarked public abstract class ArchitectureTestBase { + protected static final Features FEATURES = new Features(); + + @Setter + @Getter + @Accessors(fluent = true) + protected static class Features { + private Features.State blacklistMethods = Features.State.ENABLED; + private Features.State blacklistClasses = Features.State.ENABLED; + private Features.State blacklistAnnotations = Features.State.ENABLED; + private Features.State enforceJspecify = Features.State.ENABLED; + + protected Features() { + } + + public enum State { + ENABLED, DISABLED + } + } + protected static final Set BLACKLISTED_METHODS = new HashSet<>( Set.of( // We should use `assertThatExceptionOfType(...).isThrownBy(...)` instead of `assertThatThrownBy(...)` @@ -194,152 +216,160 @@ void nested_test_classes_have_matching_production_method_name(JavaClasses classe @SuppressWarnings("unused") @ArchTest - static final ArchRule no_blacklisted_methods_are_used = classes() - .should(new ArchCondition<>("not use blacklisted methods or statically import them") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - // Check all method calls from this class - for (var method : javaClass.getMethods()) { - for (var methodCall : method.getMethodCallsFromSelf()) { - var fullMethodName = "%s.%s".formatted( - methodCall.getTargetOwner().getFullName(), - methodCall.getTarget().getName() - ); - - if (BLACKLISTED_METHODS.contains(fullMethodName)) { - var message = String.format( - "Method %s calls blacklisted method %s (%s.java:%d)", - method.getFullName(), - fullMethodName, - javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(method, message)); + static final ArchRule no_blacklisted_methods_are_used = FEATURES.blacklistMethods() == Features.State.DISABLED + ? new InnertRule() + : classes() + .should(new ArchCondition<>("not use blacklisted methods or statically import them") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + // Check all method calls from this class + for (var method : javaClass.getMethods()) { + for (var methodCall : method.getMethodCallsFromSelf()) { + var fullMethodName = "%s.%s".formatted( + methodCall.getTargetOwner().getFullName(), + methodCall.getTarget().getName() + ); + + if (BLACKLISTED_METHODS.contains(fullMethodName)) { + var message = String.format( + "Method %s calls blacklisted method %s (%s.java:%d)", + method.getFullName(), + fullMethodName, + javaClass.getSimpleName(), + methodCall.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } } - } - } - // Check static initializers for method calls - javaClass.getStaticInitializer().ifPresent(staticInitializer -> { - for (var methodCall : staticInitializer.getMethodCallsFromSelf()) { - var fullMethodName = "%s.%s".formatted( - methodCall.getTargetOwner().getFullName(), - methodCall.getTarget().getName() - ); - - if (BLACKLISTED_METHODS.contains(fullMethodName)) { - var message = String.format( - "Static initializer in %s calls blacklisted method %s (%s.java:%d)", - javaClass.getFullName(), - fullMethodName, - javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(staticInitializer, message)); - } + // Check static initializers for method calls + javaClass.getStaticInitializer().ifPresent(staticInitializer -> { + for (var methodCall : staticInitializer.getMethodCallsFromSelf()) { + var fullMethodName = "%s.%s".formatted( + methodCall.getTargetOwner().getFullName(), + methodCall.getTarget().getName() + ); + + if (BLACKLISTED_METHODS.contains(fullMethodName)) { + var message = String.format( + "Static initializer in %s calls blacklisted method %s (%s.java:%d)", + javaClass.getFullName(), + fullMethodName, + javaClass.getSimpleName(), + methodCall.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(staticInitializer, message)); + } + } + }); } }); - } - }); @SuppressWarnings("unused") @ArchTest - static final ArchRule no_blacklisted_annotations_are_used = classes() - .should(new ArchCondition<>( - "not use blacklisted annotations on classes, methods, method parameters, or fields" - ) { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - // Check annotations on the class itself - for (var annotation : javaClass.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Class %s is annotated with blacklisted annotation @%s (%s.java:%d)", - javaClass.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(javaClass, message)); - } - } - - // Check annotations on methods and their parameters - for (var method : javaClass.getMethods()) { - // Check method annotations - for (var annotation : method.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Method %s is annotated with blacklisted annotation @%s (%s.java:%d)", - method.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(method, message)); - } - } - // Check method parameter annotations - for (var parameter : method.getParameters()) { - for (var annotation : parameter.getAnnotations()) { + static final ArchRule no_blacklisted_annotations_are_used = FEATURES.blacklistAnnotations() == Features.State.DISABLED + ? new InnertRule() + : classes() + .should(new ArchCondition<>( + "not use blacklisted annotations on classes, methods, method parameters, or fields" + ) { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + // Check annotations on the class itself + for (var annotation : javaClass.getAnnotations()) { if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { var message = String.format( - "Parameter %s of method %s is annotated with blacklisted annotation @%s (%s.java:%d)", - parameter.getIndex(), - method.getFullName(), + "Class %s is annotated with blacklisted annotation @%s (%s.java:%d)", + javaClass.getFullName(), annotation.getRawType().getFullName(), javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() - ); // Parameter doesn't have its own SLOC, use method's - events.add(SimpleConditionEvent.violated(parameter, message)); + javaClass.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(javaClass, message)); } } - } - } - // Check annotations on fields (ArchUnit includes record components as fields) - for (var field : javaClass.getFields()) { - for (var annotation : field.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Field %s in class %s is annotated with blacklisted annotation @%s (%s.java:%d)", - field.getName(), - javaClass.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - field.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(field, message)); + // Check annotations on methods and their parameters + for (var method : javaClass.getMethods()) { + // Check method annotations + for (var annotation : method.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Method %s is annotated with blacklisted annotation @%s (%s.java:%d)", + method.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + method.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } + // Check method parameter annotations + for (var parameter : method.getParameters()) { + for (var annotation : parameter.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Parameter %s of method %s is annotated with blacklisted annotation @%s (%s.java:%d)", + parameter.getIndex(), + method.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + method.getSourceCodeLocation().getLineNumber() + ); // Parameter doesn't have its own SLOC, use method's + events.add(SimpleConditionEvent.violated(parameter, message)); + } + } + } + } + + // Check annotations on fields (ArchUnit includes record components as fields) + for (var field : javaClass.getFields()) { + for (var annotation : field.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Field %s in class %s is annotated with blacklisted annotation @%s (%s.java:%d)", + field.getName(), + javaClass.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + field.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(field, message)); + } + } } } - } - } - }); + }); @SuppressWarnings("unused") @ArchTest - static final ArchRule no_blacklisted_classes_are_used = noClasses() - .should() - .dependOnClassesThat( - new DescribedPredicate<>("not use blacklisted classes") { - @Override - public boolean test(JavaClass javaClass) { - return BLACKLISTED_CLASSES.contains(javaClass.getFullName()); - } - } - ); + static final ArchRule no_blacklisted_classes_are_used = FEATURES.blacklistClasses() == Features.State.DISABLED + ? new InnertRule() + : noClasses() + .should() + .dependOnClassesThat( + new DescribedPredicate<>("not use blacklisted classes") { + @Override + public boolean test(JavaClass javaClass) { + return BLACKLISTED_CLASSES.contains(javaClass.getFullName()); + } + } + ); @SuppressWarnings("unused") @ArchTest - static final ArchRule top_level_classes_must_be_annotated_with_jspecify = classes() - .that() - .areTopLevelClasses() - .and() - .areNotAnnotations() - .should() - .beAnnotatedWith(org.jspecify.annotations.NullMarked.class) - .orShould() - .beAnnotatedWith(org.jspecify.annotations.NullUnmarked.class); + static final ArchRule top_level_classes_must_be_annotated_with_jspecify = FEATURES.enforceJspecify() == Features.State.DISABLED + ? new InnertRule() + : classes() + .that() + .areTopLevelClasses() + .and() + .areNotAnnotations() + .should() + .beAnnotatedWith(org.jspecify.annotations.NullMarked.class) + .orShould() + .beAnnotatedWith(org.jspecify.annotations.NullUnmarked.class); /* ****************************************************************** */ diff --git a/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java b/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java new file mode 100644 index 0000000..65d9cbf --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java @@ -0,0 +1,41 @@ +package it.aboutbits.archunit.toolbox; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.EvaluationResult; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullUnmarked; + +@Slf4j +@NullUnmarked +final class InnertRule implements ArchRule { + @Override + public void check(JavaClasses javaClasses) { + log.info("Rule disabled by config."); + } + + @Override + public ArchRule because(String s) { + return null; + } + + @Override + public ArchRule allowEmptyShould(boolean b) { + return null; + } + + @Override + public ArchRule as(String s) { + return null; + } + + @Override + public EvaluationResult evaluate(JavaClasses javaClasses) { + return null; + } + + @Override + public String getDescription() { + return ""; + } +} From 85fff610336dce2169a5e18a6bbe10f70ae51dc3 Mon Sep 17 00:00:00 2001 From: AboutBits Tech Date: Wed, 28 Jan 2026 15:32:00 +0000 Subject: [PATCH 02/10] 1.2.0-RC1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e477f28..9861f7f 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ it.aboutbits archunit-toolbox - 1.1.0 + 1.2.0-RC1 Common ArchUnit tooling for Java / Spring Boot projects. From 766dcb1166a77b41d80f75a17c4ebe4df4427e81 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 25 Mar 2026 16:29:26 +0100 Subject: [PATCH 03/10] break up rules --- pom.xml | 3 +- .../toolbox/ArchitectureTestBase.java | 560 ------------------ .../toolbox/BaseArchRuleCollection.java | 25 + .../toolbox/CommonArchRuleCollection.java | 9 + .../archunit/toolbox/InnertRule.java | 41 -- .../toolbox/config/ArchRuleConfig.java | 27 + .../base/BlacklistAnnotationsArchRule.java | 140 +++++ .../rule/base/BlacklistClassesArchRule.java | 40 ++ .../rule/base/BlacklistMethodsArchRule.java | 119 ++++ .../rule/base/EnforceJspecifyArchRule.java | 26 + .../TestClassInCorrectPackageArchRule.java | 73 +++ .../base/TestClassVisibilityArchRule.java | 25 + .../base/TestMethodVisibilityArchRule.java | 38 ++ .../TestNestedClassMatchNameArchRule.java | 185 ++++++ .../TestNestedClassVisibilityArchRule.java | 23 + ...erRequestMappingsMustBeSecurityTested.java | 108 ++++ .../SortMappingsExhaustiveArchRule.java | 195 ++++++ .../archunit/toolbox/util/LineNumberUtil.java | 159 +++++ .../archunit/toolbox/ArchitectureTest.java | 3 +- 19 files changed, 1196 insertions(+), 603 deletions(-) delete mode 100644 src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java delete mode 100644 src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/config/ArchRuleConfig.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistClassesArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/EnforceJspecifyArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassInCorrectPackageArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassVisibilityArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassVisibilityArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java diff --git a/pom.xml b/pom.xml index 9861f7f..4915632 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 diff --git a/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java b/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java deleted file mode 100644 index 8e9050f..0000000 --- a/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java +++ /dev/null @@ -1,560 +0,0 @@ -package it.aboutbits.archunit.toolbox; - -import com.tngtech.archunit.base.DescribedPredicate; -import com.tngtech.archunit.core.domain.JavaClass; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.domain.JavaMethod; -import com.tngtech.archunit.junit.ArchTest; -import com.tngtech.archunit.lang.ArchCondition; -import com.tngtech.archunit.lang.ArchRule; -import com.tngtech.archunit.lang.ConditionEvents; -import com.tngtech.archunit.lang.SimpleConditionEvent; -import lombok.Getter; -import lombok.Setter; -import lombok.experimental.Accessors; -import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.NullMarked; - -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; - -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -@SuppressWarnings({"checkstyle:ConstantName", "checkstyle:MethodName", "java:S100"}) -@Slf4j -@NullMarked -public abstract class ArchitectureTestBase { - protected static final Features FEATURES = new Features(); - - @Setter - @Getter - @Accessors(fluent = true) - protected static class Features { - private Features.State blacklistMethods = Features.State.ENABLED; - private Features.State blacklistClasses = Features.State.ENABLED; - private Features.State blacklistAnnotations = Features.State.ENABLED; - private Features.State enforceJspecify = Features.State.ENABLED; - - protected Features() { - } - - public enum State { - ENABLED, DISABLED - } - } - - protected static final Set BLACKLISTED_METHODS = new HashSet<>( - Set.of( - // We should use `assertThatExceptionOfType(...).isThrownBy(...)` instead of `assertThatThrownBy(...)` - "org.assertj.core.api.Assertions.assertThatThrownBy", - "org.junit.jupiter.api.Assertions.assertThrows", - "org.junit.jupiter.api.Assertions.assertDoesNotThrow", - // assertThat (allowed is only org.assertj.core.api.Assertions.assertThat) - "org.assertj.core.api.AssertionsForClassTypes.assertThat", - "org.assertj.core.api.AssertionsForClassTypes.assertThatCode", - "org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType", - "org.assertj.core.api.AssertionsForInterfaceTypes.assertThat", - "org.assertj.core.api.ClassBasedNavigableIterableAssert.assertThat", - "org.assertj.core.api.ClassBasedNavigableListAssert.assertThat", - "org.assertj.core.api.FactoryBasedNavigableIterableAssert.assertThat", - "org.assertj.core.api.FactoryBasedNavigableListAssert.assertThat", - "org.assertj.core.api.Java6Assertions.assertThat", - "org.hamcrest.MatcherAssert.assertThat", - "org.junit.Assert.assertArrayEquals", - "org.junit.Assert.assertEquals", - "org.junit.Assert.assertFalse", - "org.junit.Assert.assertNotEquals", - "org.junit.Assert.assertNotSame", - "org.junit.Assert.assertSame", - "org.junit.Assert.assertThat", - "org.junit.Assert.assertThrows", - "org.junit.Assert.assertTrue", - "org.junit.Assert.fail", - // use assertThat (org.assertj.core.api.Assertions.assertThat) - "org.junit.jupiter.api.Assertions.assertArrayEquals", - "org.junit.jupiter.api.Assertions.assertEquals", - "org.junit.jupiter.api.Assertions.assertFalse", - "org.junit.jupiter.api.Assertions.assertInstanceOf", - "org.junit.jupiter.api.Assertions.assertIterableEquals", - "org.junit.jupiter.api.Assertions.assertNotEquals", - "org.junit.jupiter.api.Assertions.assertNotNull", - "org.junit.jupiter.api.Assertions.assertNull", - "org.junit.jupiter.api.Assertions.assertTrue", - "org.testcontainers.shaded.org.hamcrest.MatcherAssert.assertThat" - ) - ); - - protected static final Set BLACKLISTED_CLASSES = new HashSet<>( - Set.of( - // use FakerExtended from toolbox - "net.datafaker.Faker" - ) - ); - - protected static final Set BLACKLISTED_ANNOTATIONS = new HashSet<>( - Set.of( - "org.junit.After", - "org.junit.AfterClass", - "org.junit.Before", - "org.junit.BeforeClass", - "org.junit.ClassRule", - "org.junit.FixMethodOrder", - "org.junit.Ignore", - "org.junit.Rule", - "org.junit.Test", - // @NonNull (allowed is only org.jspecify.annotations.NonNull) - "lombok.NonNull", - "edu.umd.cs.findbugs.annotations.NonNull", - "io.micrometer.common.lang.NonNull", - "io.micrometer.core.lang.NonNull", - "org.springframework.lang.NonNull", - "org.testcontainers.shaded.org.checkerframework.checker.nullness.qual.NonNull", - // @NotNull (allowed is only jakarta.validation.constraints.NotNull) - "com.drew.lang.annotations.NotNull", - "com.sun.istack.NotNull", - "org.antlr.v4.runtime.misc.NotNull", - "org.jetbrains.annotations.NotNull", - "software.amazon.awssdk.annotations.NotNull", - // @Nullable (allowed is only org.jspecify.annotations.Nullable) - "org.springframework.lang.Nullable", - "com.drew.lang.annotations.Nullable", - "com.sun.istack.Nullable", - "edu.umd.cs.findbugs.annotations.Nullable", - "io.micrometer.common.lang.Nullable", - "io.micrometer.core.lang.Nullable", - "jakarta.annotation.Nullable", - "javax.annotation.Nullable", - "org.jetbrains.annotations.Nullable", - "org.testcontainers.shaded.org.checkerframework.checker.nullness.qual.Nullable", - // @Transactional (allowed is only org.springframework.transaction.annotation.Transactional) - "jakarta.transaction.Transactional" - ) - ); - - /** - * List of supported test class name suffixes. - *

- * When introducing a new test type (e.g. IntegrationTest), add its suffix here - * instead of directly modifying the regex pattern. - **/ - protected static final Set TEST_CLASS_SUFFIXES = new HashSet<>( - Set.of( - "Test", - "CacheTest", - "EventTest", - "SecurityTest" - ) - ); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule test_classes_must_be_package_private = classes() - .that() - .haveNameMatching(getTestClassRegex()) - .and() - .resideOutsideOfPackages(".._support..", ".._config..") - .should() - .bePackagePrivate(); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule nested_test_classes_must_be_package_private = classes() - .that() - .areAnnotatedWith(org.junit.jupiter.api.Nested.class) - .should() - .bePackagePrivate() - .allowEmptyShould(true); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule test_methods_must_be_package_private = methods() - .that() - .areAnnotatedWith(org.junit.jupiter.api.Test.class) - .or() - .areAnnotatedWith(org.junit.jupiter.api.RepeatedTest.class) - .or() - .areAnnotatedWith(org.junit.jupiter.params.ParameterizedTest.class) - .or() - .areAnnotatedWith(com.tngtech.archunit.junit.ArchTest.class) - .should() - .bePackagePrivate(); - - @ArchTest - void test_classes_should_be_in_the_same_package_as_their_production_code(JavaClasses classes) { - classes().that() - .haveNameMatching(getTestClassRegex()) - .and() - .doNotHaveSimpleName("ArchitectureTest") - .and() - .areNotAnnotatedWith(org.junit.jupiter.api.Disabled.class) - .and() - .areNotAnnotatedWith(com.tngtech.archunit.junit.ArchIgnore.class) - .and() - .areNotAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreNoProductionCounterpart.class) - .and() - .resideOutsideOfPackages(".._support..", ".._config..") - .should(beInTheSamePackageAsProductionClass(classes)) - .allowEmptyShould(true) - .check(classes); - } - - @ArchTest - void nested_test_classes_have_matching_production_method_name(JavaClasses classes) { - classes().that() - .haveNameMatching(getTestClassRegex()) - .and() - .areNotAnnotatedWith(org.junit.jupiter.api.Disabled.class) - .and() - .areNotAnnotatedWith(com.tngtech.archunit.junit.ArchIgnore.class) - .should(nestedClassesMatchProdMethodName(classes)) - .allowEmptyShould(true) - .check(classes); - } - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule no_blacklisted_methods_are_used = FEATURES.blacklistMethods() == Features.State.DISABLED - ? new InnertRule() - : classes() - .should(new ArchCondition<>("not use blacklisted methods or statically import them") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - // Check all method calls from this class - for (var method : javaClass.getMethods()) { - for (var methodCall : method.getMethodCallsFromSelf()) { - var fullMethodName = "%s.%s".formatted( - methodCall.getTargetOwner().getFullName(), - methodCall.getTarget().getName() - ); - - if (BLACKLISTED_METHODS.contains(fullMethodName)) { - var message = String.format( - "Method %s calls blacklisted method %s (%s.java:%d)", - method.getFullName(), - fullMethodName, - javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(method, message)); - } - } - } - - // Check static initializers for method calls - javaClass.getStaticInitializer().ifPresent(staticInitializer -> { - for (var methodCall : staticInitializer.getMethodCallsFromSelf()) { - var fullMethodName = "%s.%s".formatted( - methodCall.getTargetOwner().getFullName(), - methodCall.getTarget().getName() - ); - - if (BLACKLISTED_METHODS.contains(fullMethodName)) { - var message = String.format( - "Static initializer in %s calls blacklisted method %s (%s.java:%d)", - javaClass.getFullName(), - fullMethodName, - javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(staticInitializer, message)); - } - } - }); - } - }); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule no_blacklisted_annotations_are_used = FEATURES.blacklistAnnotations() == Features.State.DISABLED - ? new InnertRule() - : classes() - .should(new ArchCondition<>( - "not use blacklisted annotations on classes, methods, method parameters, or fields" - ) { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - // Check annotations on the class itself - for (var annotation : javaClass.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Class %s is annotated with blacklisted annotation @%s (%s.java:%d)", - javaClass.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(javaClass, message)); - } - } - - // Check annotations on methods and their parameters - for (var method : javaClass.getMethods()) { - // Check method annotations - for (var annotation : method.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Method %s is annotated with blacklisted annotation @%s (%s.java:%d)", - method.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(method, message)); - } - } - // Check method parameter annotations - for (var parameter : method.getParameters()) { - for (var annotation : parameter.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Parameter %s of method %s is annotated with blacklisted annotation @%s (%s.java:%d)", - parameter.getIndex(), - method.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() - ); // Parameter doesn't have its own SLOC, use method's - events.add(SimpleConditionEvent.violated(parameter, message)); - } - } - } - } - - // Check annotations on fields (ArchUnit includes record components as fields) - for (var field : javaClass.getFields()) { - for (var annotation : field.getAnnotations()) { - if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { - var message = String.format( - "Field %s in class %s is annotated with blacklisted annotation @%s (%s.java:%d)", - field.getName(), - javaClass.getFullName(), - annotation.getRawType().getFullName(), - javaClass.getSimpleName(), - field.getSourceCodeLocation().getLineNumber() - ); - events.add(SimpleConditionEvent.violated(field, message)); - } - } - } - } - }); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule no_blacklisted_classes_are_used = FEATURES.blacklistClasses() == Features.State.DISABLED - ? new InnertRule() - : noClasses() - .should() - .dependOnClassesThat( - new DescribedPredicate<>("not use blacklisted classes") { - @Override - public boolean test(JavaClass javaClass) { - return BLACKLISTED_CLASSES.contains(javaClass.getFullName()); - } - } - ); - - @SuppressWarnings("unused") - @ArchTest - static final ArchRule top_level_classes_must_be_annotated_with_jspecify = FEATURES.enforceJspecify() == Features.State.DISABLED - ? new InnertRule() - : classes() - .that() - .areTopLevelClasses() - .and() - .areNotAnnotations() - .should() - .beAnnotatedWith(org.jspecify.annotations.NullMarked.class) - .orShould() - .beAnnotatedWith(org.jspecify.annotations.NullUnmarked.class); - - /* ****************************************************************** */ - - private static ArchCondition beInTheSamePackageAsProductionClass(JavaClasses allClasses) { - return new ArchCondition<>("be in the same package as their production class") { - @Override - public void check(JavaClass testClass, ConditionEvents events) { - var testClassName = testClass.getSimpleName(); - if (!testClassName.endsWith("Test")) { - return; - } - - // Derive the production class name - var productionClassSimpleName = testClassName.replaceAll(getTestClassSuffixRegex(), ""); - var productionClassFullName = testClass.getPackageName() + "." + productionClassSimpleName; - - // Check if the production class exists in the same package - var productionClass = allClasses.stream() - .filter(clazz -> clazz.getFullName().equals(productionClassFullName)) - .findFirst(); - - if (productionClass.isEmpty()) { - var message = "Test class <%s> does not have a matching production class <%s> in the same package (%s.java:0)".formatted( - testClass.getFullName(), - productionClassFullName, - productionClassSimpleName - ); - events.add(SimpleConditionEvent.violated(testClass, message)); - } - } - }; - } - - @SuppressWarnings("java:S3776") - private static ArchCondition nestedClassesMatchProdMethodName(JavaClasses allClasses) { - return new ArchCondition<>("have a @Nested class that matches the method name in the production code class") { - @Override - public void check(JavaClass testClass, ConditionEvents events) { - // Find all @Nested classes that are nested in the test class, except the $Validation classes - var nestedClasses = testClass.getPackage() - .getClasses() - .stream() - .filter(clazz -> clazz.getName().startsWith(testClass.getName() + "$") - && clazz.isAnnotatedWith(org.junit.jupiter.api.Nested.class) - && !clazz.isAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreGroupName.class) - && !clazz.getName().endsWith("$Validation") - ) - .collect(Collectors.toSet()); - - if (nestedClasses.isEmpty()) { - return; - } - - for (var nestedClass : nestedClasses) { - /* - * We want to skip classes that are a @Nested Group - * (for example, $ExportAction of class RwValueListGroupTest) - * as they are not directly related to a method in the production class, - * but the @Nested classes within this @Nested Group class are. - * - * Example: - * We want to check if method deleteAll - * of @Nested test class - * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction$DeleteAll - * exists in production class - * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroup - * but this code skips @Nested class - * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction - */ - if (nestedClass.getPackage() - .getClasses() - .stream() - .anyMatch(clazz -> clazz.getName().startsWith(nestedClass.getName() + "$") - && clazz.isAnnotatedWith(org.junit.jupiter.api.Nested.class) - && !clazz.isAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreGroupName.class) - && !clazz.getName().endsWith("$Validation") - ) - ) { - continue; - } - - var nestedClassName = nestedClass.getSimpleName(); - var expectedMethodName = Character.toLowerCase(nestedClassName.charAt(0)) - + nestedClassName.substring(1); - - var nestedClassBaseClassSimpleName = nestedClass.getName() - .replace(nestedClass.getPackageName() + ".", "") - .replaceAll("\\$.+", ""); - var nestedClassLineNumber = nestedClass.getConstructors() - .iterator() - .next() - .getSourceCodeLocation() - .getLineNumber(); - - /* - * This is only true for inner @Nested group classes like for example - * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction$DeleteAll - * where the enclosing class is - * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction - * - * If an enclosing class is found, this will produce a suffix like "$DeleteAction" - */ - var enclosingClassSuffix = nestedClass.getEnclosingClass() - .map(enclosingClass -> { - if (!enclosingClass.getName().contains("$")) { - return null; - } - - return "$%s".formatted(enclosingClass.getSimpleName()); - }); - - var productionClassName = "%s.%s%s".formatted( - testClass.getPackageName(), - testClass.getSimpleName().replaceAll(getTestClassSuffixRegex(), ""), - enclosingClassSuffix.orElse("") - ); - - var productionClassOptional = allClasses.stream() - .filter(clazz -> clazz.getFullName().equals(productionClassName)) - .findFirst(); - - if (productionClassOptional.isEmpty() && enclosingClassSuffix.isPresent()) { - var message = "The @Nested test class <%s> (%s.java:%s)%ndoes not have a matching production class <%s>".formatted( - nestedClass.getName(), - nestedClassBaseClassSimpleName, - nestedClassLineNumber, - productionClassName - ); - events.add(SimpleConditionEvent.violated(nestedClass, message)); - } - - if (productionClassOptional.isPresent()) { - var productionClass = productionClassOptional.get(); - - var methodExists = productionClass.getMethods() - .stream() - .map(JavaMethod::getName) - .anyMatch(methodName -> methodName.equals(expectedMethodName)); - - if (!methodExists) { - int productionClassLineNumber = -1; - - try { - productionClassLineNumber = productionClass.getConstructors() - .iterator() - .next() - .getSourceCodeLocation() - .getLineNumber(); - } catch (Exception _) { - log.error( - "Failed to resolve productionClassLineNumber. [nestedClass.getName()={}, nestedClassBaseClassSimpleName={}, nestedClassLineNumber={}, expectedMethodName={}, productionClass.getName()={}]", - nestedClass.getName(), - nestedClassBaseClassSimpleName, - nestedClassLineNumber, - expectedMethodName, - productionClass.getName() - ); - } - - var message = "The @Nested test class <%s> (%s.java:%s)%ndoes not match any expected method name <%s> in production class <%s> (%s.java:%s)".formatted( - nestedClass.getName(), - nestedClassBaseClassSimpleName, - nestedClassLineNumber, - expectedMethodName, - productionClass.getName(), - productionClass.getName() - .replace(nestedClass.getPackageName() + ".", "") - .replaceAll("\\$.+", ""), - productionClassLineNumber - ); - events.add(SimpleConditionEvent.violated(nestedClass, message)); - } - } - } - } - }; - } - - /* ****************************************************************** */ - - protected static String getTestClassSuffixRegex() { - return "(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$"; - } - - protected static String getTestClassRegex() { - return ".+%s".formatted(getTestClassSuffixRegex()); - } -} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java b/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java new file mode 100644 index 0000000..be44859 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java @@ -0,0 +1,25 @@ +package it.aboutbits.archunit.toolbox; + +import it.aboutbits.archunit.toolbox.rule.base.BlacklistAnnotationsArchRule; +import it.aboutbits.archunit.toolbox.rule.base.BlacklistClassesArchRule; +import it.aboutbits.archunit.toolbox.rule.base.BlacklistMethodsArchRule; +import it.aboutbits.archunit.toolbox.rule.base.EnforceJspecifyArchRule; +import it.aboutbits.archunit.toolbox.rule.base.TestClassInCorrectPackageArchRule; +import it.aboutbits.archunit.toolbox.rule.base.TestClassVisibilityArchRule; +import it.aboutbits.archunit.toolbox.rule.base.TestMethodVisibilityArchRule; +import it.aboutbits.archunit.toolbox.rule.base.TestNestedClassMatchNameArchRule; +import it.aboutbits.archunit.toolbox.rule.base.TestNestedClassVisibilityArchRule; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface BaseArchRuleCollection extends + BlacklistAnnotationsArchRule, + BlacklistClassesArchRule, + BlacklistMethodsArchRule, + EnforceJspecifyArchRule, + TestClassInCorrectPackageArchRule, + TestClassVisibilityArchRule, + TestMethodVisibilityArchRule, + TestNestedClassMatchNameArchRule, + TestNestedClassVisibilityArchRule { +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java b/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java new file mode 100644 index 0000000..906b8b3 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java @@ -0,0 +1,9 @@ +package it.aboutbits.archunit.toolbox; + +import it.aboutbits.archunit.toolbox.rule.common.ControllerRequestMappingsMustBeSecurityTested; +import it.aboutbits.archunit.toolbox.rule.common.SortMappingsExhaustiveArchRule; + +public interface CommonArchRuleCollection extends + ControllerRequestMappingsMustBeSecurityTested, + SortMappingsExhaustiveArchRule { +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java b/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java deleted file mode 100644 index 65d9cbf..0000000 --- a/src/main/java/it/aboutbits/archunit/toolbox/InnertRule.java +++ /dev/null @@ -1,41 +0,0 @@ -package it.aboutbits.archunit.toolbox; - -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.lang.ArchRule; -import com.tngtech.archunit.lang.EvaluationResult; -import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.NullUnmarked; - -@Slf4j -@NullUnmarked -final class InnertRule implements ArchRule { - @Override - public void check(JavaClasses javaClasses) { - log.info("Rule disabled by config."); - } - - @Override - public ArchRule because(String s) { - return null; - } - - @Override - public ArchRule allowEmptyShould(boolean b) { - return null; - } - - @Override - public ArchRule as(String s) { - return null; - } - - @Override - public EvaluationResult evaluate(JavaClasses javaClasses) { - return null; - } - - @Override - public String getDescription() { - return ""; - } -} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/config/ArchRuleConfig.java b/src/main/java/it/aboutbits/archunit/toolbox/config/ArchRuleConfig.java new file mode 100644 index 0000000..136346b --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/config/ArchRuleConfig.java @@ -0,0 +1,27 @@ +package it.aboutbits.archunit.toolbox.config; + +import org.jspecify.annotations.NullMarked; + +import java.util.HashSet; +import java.util.Set; + +@NullMarked +public final class ArchRuleConfig { + private ArchRuleConfig() { + } + + /** + * List of supported test class name suffixes. + *

+ * When introducing a new test type (e.g. IntegrationTest), add its suffix here + * instead of directly modifying the regex pattern. + **/ + public static final Set TEST_CLASS_SUFFIXES = new HashSet<>( + Set.of( + "Test", + "CacheTest", + "EventTest", + "SecurityTest" + ) + ); +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java new file mode 100644 index 0000000..99757df --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java @@ -0,0 +1,140 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; + +import java.util.HashSet; +import java.util.Set; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface BlacklistAnnotationsArchRule { + @SuppressWarnings("java:S2386") + Set BLACKLISTED_ANNOTATIONS = new HashSet<>( + Set.of( + "org.junit.After", + "org.junit.AfterClass", + "org.junit.Before", + "org.junit.BeforeClass", + "org.junit.ClassRule", + "org.junit.FixMethodOrder", + "org.junit.Ignore", + "org.junit.Rule", + "org.junit.Test", + // @NonNull (allowed is only org.jspecify.annotations.NonNull) + "lombok.NonNull", + "edu.umd.cs.findbugs.annotations.NonNull", + "io.micrometer.common.lang.NonNull", + "io.micrometer.core.lang.NonNull", + "org.springframework.lang.NonNull", + "org.testcontainers.shaded.org.checkerframework.checker.nullness.qual.NonNull", + // @NotNull (allowed is only jakarta.validation.constraints.NotNull) + "com.drew.lang.annotations.NotNull", + "com.sun.istack.NotNull", + "org.antlr.v4.runtime.misc.NotNull", + "org.jetbrains.annotations.NotNull", + "software.amazon.awssdk.annotations.NotNull", + // @Nullable (allowed is only org.jspecify.annotations.Nullable) + "org.springframework.lang.Nullable", + "com.drew.lang.annotations.Nullable", + "com.sun.istack.Nullable", + "edu.umd.cs.findbugs.annotations.Nullable", + "io.micrometer.common.lang.Nullable", + "io.micrometer.core.lang.Nullable", + "jakarta.annotation.Nullable", + "javax.annotation.Nullable", + "org.jetbrains.annotations.Nullable", + "org.testcontainers.shaded.org.checkerframework.checker.nullness.qual.Nullable", + // @Transactional (allowed is only org.springframework.transaction.annotation.Transactional) + "jakarta.transaction.Transactional" + ) + ); + + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void no_blacklisted_annotations_are_used(JavaClasses classes) { + classes() + .should(new NotUseBlacklistedAnnotations()) + .check(classes); + } + + class NotUseBlacklistedAnnotations extends ArchCondition { + public NotUseBlacklistedAnnotations() { + super("not use blacklisted annotations on classes, methods, method parameters, or fields"); + } + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + // Check annotations on the class itself + for (var annotation : javaClass.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Class %s is annotated with blacklisted annotation @%s (%s.java:%d)", + javaClass.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + javaClass.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(javaClass, message)); + } + } + + // Check annotations on methods and their parameters + for (var method : javaClass.getMethods()) { + // Check method annotations + for (var annotation : method.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Method %s is annotated with blacklisted annotation @%s (%s.java:%d)", + method.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + method.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } + // Check method parameter annotations + for (var parameter : method.getParameters()) { + for (var annotation : parameter.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Parameter %s of method %s is annotated with blacklisted annotation @%s (%s.java:%d)", + parameter.getIndex(), + method.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + method.getSourceCodeLocation().getLineNumber() + ); // Parameter doesn't have its own SLOC, use method's + events.add(SimpleConditionEvent.violated(parameter, message)); + } + } + } + } + + // Check annotations on fields (ArchUnit includes record components as fields) + for (var field : javaClass.getFields()) { + for (var annotation : field.getAnnotations()) { + if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) { + var message = String.format( + "Field %s in class %s is annotated with blacklisted annotation @%s (%s.java:%d)", + field.getName(), + javaClass.getFullName(), + annotation.getRawType().getFullName(), + javaClass.getSimpleName(), + field.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(field, message)); + } + } + } + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistClassesArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistClassesArchRule.java new file mode 100644 index 0000000..2e77443 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistClassesArchRule.java @@ -0,0 +1,40 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import org.jspecify.annotations.NullMarked; + +import java.util.HashSet; +import java.util.Set; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface BlacklistClassesArchRule { + @SuppressWarnings("java:S2386") + Set BLACKLISTED_CLASSES = new HashSet<>( + Set.of( + // use FakerExtended from toolbox + "net.datafaker.Faker" + ) + ); + + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void no_blacklisted_classes_are_used(JavaClasses classes) { + noClasses() + .should() + .dependOnClassesThat( + new DescribedPredicate<>("not use blacklisted classes") { + @Override + public boolean test(JavaClass javaClass) { + return BLACKLISTED_CLASSES.contains(javaClass.getFullName()); + } + } + ) + .check(classes); + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java new file mode 100644 index 0000000..01bf199 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java @@ -0,0 +1,119 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; + +import java.util.HashSet; +import java.util.Set; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface BlacklistMethodsArchRule { + @SuppressWarnings("java:S2386") + Set BLACKLISTED_METHODS = new HashSet<>( + Set.of( + // We should use `assertThatExceptionOfType(...).isThrownBy(...)` instead of `assertThatThrownBy(...)` + "org.assertj.core.api.Assertions.assertThatThrownBy", + "org.junit.jupiter.api.Assertions.assertThrows", + "org.junit.jupiter.api.Assertions.assertDoesNotThrow", + // assertThat (allowed is only org.assertj.core.api.Assertions.assertThat) + "org.assertj.core.api.AssertionsForClassTypes.assertThat", + "org.assertj.core.api.AssertionsForClassTypes.assertThatCode", + "org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType", + "org.assertj.core.api.AssertionsForInterfaceTypes.assertThat", + "org.assertj.core.api.ClassBasedNavigableIterableAssert.assertThat", + "org.assertj.core.api.ClassBasedNavigableListAssert.assertThat", + "org.assertj.core.api.FactoryBasedNavigableIterableAssert.assertThat", + "org.assertj.core.api.FactoryBasedNavigableListAssert.assertThat", + "org.assertj.core.api.Java6Assertions.assertThat", + "org.hamcrest.MatcherAssert.assertThat", + "org.junit.Assert.assertArrayEquals", + "org.junit.Assert.assertEquals", + "org.junit.Assert.assertFalse", + "org.junit.Assert.assertNotEquals", + "org.junit.Assert.assertNotSame", + "org.junit.Assert.assertSame", + "org.junit.Assert.assertThat", + "org.junit.Assert.assertThrows", + "org.junit.Assert.assertTrue", + "org.junit.Assert.fail", + // use assertThat (org.assertj.core.api.Assertions.assertThat) + "org.junit.jupiter.api.Assertions.assertArrayEquals", + "org.junit.jupiter.api.Assertions.assertEquals", + "org.junit.jupiter.api.Assertions.assertFalse", + "org.junit.jupiter.api.Assertions.assertInstanceOf", + "org.junit.jupiter.api.Assertions.assertIterableEquals", + "org.junit.jupiter.api.Assertions.assertNotEquals", + "org.junit.jupiter.api.Assertions.assertNotNull", + "org.junit.jupiter.api.Assertions.assertNull", + "org.junit.jupiter.api.Assertions.assertTrue", + "org.testcontainers.shaded.org.hamcrest.MatcherAssert.assertThat" + ) + ); + + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void no_blacklisted_methods_are_used(JavaClasses classes) { + classes() + .should(new NotUseBlacklistedMethods()) + .check(classes); + } + + class NotUseBlacklistedMethods extends ArchCondition { + public NotUseBlacklistedMethods() { + super("not use blacklisted methods or statically import them"); + } + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + // Check all method calls from this class + for (var method : javaClass.getMethods()) { + for (var methodCall : method.getMethodCallsFromSelf()) { + var fullMethodName = "%s.%s".formatted( + methodCall.getTargetOwner().getFullName(), + methodCall.getTarget().getName() + ); + + if (BLACKLISTED_METHODS.contains(fullMethodName)) { + var message = String.format( + "Method %s calls blacklisted method %s (%s.java:%d)", + method.getFullName(), + fullMethodName, + javaClass.getSimpleName(), + methodCall.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } + } + + // Check static initializers for method calls + javaClass.getStaticInitializer().ifPresent(staticInitializer -> { + for (var methodCall : staticInitializer.getMethodCallsFromSelf()) { + var fullMethodName = "%s.%s".formatted( + methodCall.getTargetOwner().getFullName(), + methodCall.getTarget().getName() + ); + + if (BLACKLISTED_METHODS.contains(fullMethodName)) { + var message = String.format( + "Static initializer in %s calls blacklisted method %s (%s.java:%d)", + javaClass.getFullName(), + fullMethodName, + javaClass.getSimpleName(), + methodCall.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(staticInitializer, message)); + } + } + }); + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/EnforceJspecifyArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/EnforceJspecifyArchRule.java new file mode 100644 index 0000000..7cfbe3c --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/EnforceJspecifyArchRule.java @@ -0,0 +1,26 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface EnforceJspecifyArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void top_level_classes_must_be_annotated_with_jspecify(JavaClasses classes) { + classes() + .that() + .areTopLevelClasses() + .and() + .areNotAnnotations() + .should() + .beAnnotatedWith(org.jspecify.annotations.NullMarked.class) + .orShould() + .beAnnotatedWith(org.jspecify.annotations.NullUnmarked.class) + .check(classes); + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassInCorrectPackageArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassInCorrectPackageArchRule.java new file mode 100644 index 0000000..6481dd7 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassInCorrectPackageArchRule.java @@ -0,0 +1,73 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.config.ArchRuleConfig.TEST_CLASS_SUFFIXES; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface TestClassInCorrectPackageArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void test_classes_should_be_in_the_same_package_as_their_production_code(JavaClasses classes) { + classes().that() + .haveNameMatching(".+(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$") + .and() + .doNotHaveSimpleName("ArchitectureTest") + .and() + .areNotAnnotatedWith(org.junit.jupiter.api.Disabled.class) + .and() + .areNotAnnotatedWith(com.tngtech.archunit.junit.ArchIgnore.class) + .and() + .areNotAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreNoProductionCounterpart.class) + .and() + .resideOutsideOfPackages(".._support..", ".._config..") + .should(new BeInTheSamePackageAsTheProductionClass(classes)) + .allowEmptyShould(true) + .check(classes); + } + + class BeInTheSamePackageAsTheProductionClass extends ArchCondition { + private final JavaClasses allClasses; + + public BeInTheSamePackageAsTheProductionClass(JavaClasses allClasses) { + super("be in the same package as their production class"); + this.allClasses = allClasses; + } + + @Override + public void check(JavaClass testClass, ConditionEvents events) { + var testClassSuffixRegex = "(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$"; + + var testClassName = testClass.getSimpleName(); + if (!testClassName.matches(testClassSuffixRegex)) { + return; + } + + // Derive the production class name + var productionClassSimpleName = testClassName.replaceAll(testClassSuffixRegex, ""); + var productionClassFullName = testClass.getPackageName() + "." + productionClassSimpleName; + + // Check if the production class exists in the same package + var productionClass = allClasses.stream() + .filter(clazz -> clazz.getFullName().equals(productionClassFullName)) + .findFirst(); + + if (productionClass.isEmpty()) { + var message = "Test class <%s> does not have a matching production class <%s> in the same package (%s.java:0)".formatted( + testClass.getFullName(), + productionClassFullName, + productionClassSimpleName + ); + events.add(SimpleConditionEvent.violated(testClass, message)); + } + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassVisibilityArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassVisibilityArchRule.java new file mode 100644 index 0000000..d601051 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestClassVisibilityArchRule.java @@ -0,0 +1,25 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.config.ArchRuleConfig.TEST_CLASS_SUFFIXES; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface TestClassVisibilityArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void test_classes_must_be_package_private(JavaClasses classes) { + classes() + .that() + .haveNameMatching(".+(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$") + .and() + .resideOutsideOfPackages(".._support..", ".._config..") + .should() + .bePackagePrivate() + .check(classes); + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java new file mode 100644 index 0000000..851d145 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java @@ -0,0 +1,38 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface TestMethodVisibilityArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void test_methods_must_be_package_private(JavaClasses classes) { + methods() + .that() + .areAnnotatedWith(org.junit.jupiter.api.Test.class) + .or() + .areAnnotatedWith(org.junit.jupiter.api.RepeatedTest.class) + .or() + .areAnnotatedWith(org.junit.jupiter.params.ParameterizedTest.class) + .or() + .areAnnotatedWith(ArchTest.class) + .and() + .areDeclaredInClassesThat(new DescribedPredicate<>("are not an interface") { + @Override + public boolean test(JavaClass javaClass) { + return !javaClass.isInterface(); + } + }) + .should() + .bePackagePrivate() + .allowEmptyShould(true) + .check(classes); + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java new file mode 100644 index 0000000..f847d16 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java @@ -0,0 +1,185 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; +import org.slf4j.LoggerFactory; + +import java.util.stream.Collectors; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.config.ArchRuleConfig.TEST_CLASS_SUFFIXES; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface TestNestedClassMatchNameArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void nested_test_classes_have_matching_production_method_name(JavaClasses classes) { + classes().that() + .haveNameMatching(".+(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$") + .and() + .areNotAnnotatedWith(org.junit.jupiter.api.Disabled.class) + .and() + .areNotAnnotatedWith(com.tngtech.archunit.junit.ArchIgnore.class) + .should(new HaveNestedClassesThatHaveAMatchingProductionMethodName(classes)) + .allowEmptyShould(true) + .check(classes); + } + + class HaveNestedClassesThatHaveAMatchingProductionMethodName extends ArchCondition { + private final JavaClasses allClasses; + + public HaveNestedClassesThatHaveAMatchingProductionMethodName(JavaClasses allClasses) { + super("have a @Nested class that matches the method name in the production code class"); + this.allClasses = allClasses; + } + + @Override + public void check(JavaClass testClass, ConditionEvents events) { + // Find all @Nested classes that are nested in the test class, except the $Validation classes + var nestedClasses = testClass.getPackage() + .getClasses() + .stream() + .filter(clazz -> clazz.getName().startsWith(testClass.getName() + "$") + && clazz.isAnnotatedWith(org.junit.jupiter.api.Nested.class) + && !clazz.isAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreGroupName.class) + && !clazz.getName().endsWith("$Validation") + ) + .collect(Collectors.toSet()); + + if (nestedClasses.isEmpty()) { + return; + } + + for (var nestedClass : nestedClasses) { + /* + * We want to skip classes that are a @Nested Group + * (for example, $ExportAction of class RwValueListGroupTest) + * as they are not directly related to a method in the production class, + * but the @Nested classes within this @Nested Group class are. + * + * Example: + * We want to check if method deleteAll + * of @Nested test class + * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction$DeleteAll + * exists in production class + * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroup + * but this code skips @Nested class + * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction + */ + if (nestedClass.getPackage() + .getClasses() + .stream() + .anyMatch(clazz -> clazz.getName().startsWith(nestedClass.getName() + "$") + && clazz.isAnnotatedWith(org.junit.jupiter.api.Nested.class) + && !clazz.isAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreGroupName.class) + && !clazz.getName().endsWith("$Validation") + ) + ) { + continue; + } + + var nestedClassName = nestedClass.getSimpleName(); + var expectedMethodName = Character.toLowerCase(nestedClassName.charAt(0)) + + nestedClassName.substring(1); + + var nestedClassBaseClassSimpleName = nestedClass.getName() + .replace(nestedClass.getPackageName() + ".", "") + .replaceAll("\\$.+", ""); + var nestedClassLineNumber = nestedClass.getConstructors() + .iterator() + .next() + .getSourceCodeLocation() + .getLineNumber(); + + /* + * This is only true for inner @Nested group classes like for example + * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction$DeleteAll + * where the enclosing class is + * it.aboutbits.example.admin.domain.rw_value.action.RwValueListActionGroupTest$DeleteAction + * + * If an enclosing class is found, this will produce a suffix like "$DeleteAction" + */ + var enclosingClassSuffix = nestedClass.getEnclosingClass() + .map(enclosingClass -> { + if (!enclosingClass.getName().contains("$")) { + return null; + } + + return "$%s".formatted(enclosingClass.getSimpleName()); + }); + + var productionClassName = "%s.%s%s".formatted( + testClass.getPackageName(), + testClass.getSimpleName() + .replaceAll("(" + String.join("|", TEST_CLASS_SUFFIXES) + ")$", ""), + enclosingClassSuffix.orElse("") + ); + + var productionClassOptional = allClasses.stream() + .filter(clazz -> clazz.getFullName().equals(productionClassName)) + .findFirst(); + + if (productionClassOptional.isEmpty() && enclosingClassSuffix.isPresent()) { + var message = "The @Nested test class <%s> (%s.java:%s)%ndoes not have a matching production class <%s>".formatted( + nestedClass.getName(), + nestedClassBaseClassSimpleName, + nestedClassLineNumber, + productionClassName + ); + events.add(SimpleConditionEvent.violated(nestedClass, message)); + } + + if (productionClassOptional.isPresent()) { + var productionClass = productionClassOptional.get(); + + var methodExists = productionClass.getMethods() + .stream() + .map(JavaMethod::getName) + .anyMatch(methodName -> methodName.equals(expectedMethodName)); + + if (!methodExists) { + int productionClassLineNumber = -1; + + try { + productionClassLineNumber = productionClass.getConstructors() + .iterator() + .next() + .getSourceCodeLocation() + .getLineNumber(); + } catch (Exception _) { + var log = LoggerFactory.getLogger(getClass()); + log.error( + "Failed to resolve productionClassLineNumber. [nestedClass.getName()={}, nestedClassBaseClassSimpleName={}, nestedClassLineNumber={}, expectedMethodName={}, productionClass.getName()={}]", + nestedClass.getName(), + nestedClassBaseClassSimpleName, + nestedClassLineNumber, + expectedMethodName, + productionClass.getName() + ); + } + + var message = "The @Nested test class <%s> (%s.java:%s)%ndoes not match any expected method name <%s> in production class <%s> (%s.java:%s)".formatted( + nestedClass.getName(), + nestedClassBaseClassSimpleName, + nestedClassLineNumber, + expectedMethodName, + productionClass.getName(), + productionClass.getName() + .replace(nestedClass.getPackageName() + ".", "") + .replaceAll("\\$.+", ""), + productionClassLineNumber + ); + events.add(SimpleConditionEvent.violated(nestedClass, message)); + } + } + } + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassVisibilityArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassVisibilityArchRule.java new file mode 100644 index 0000000..dbac531 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassVisibilityArchRule.java @@ -0,0 +1,23 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface TestNestedClassVisibilityArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void nested_test_classes_must_be_package_private(JavaClasses classes) { + classes() + .that() + .areAnnotatedWith(org.junit.jupiter.api.Nested.class) + .should() + .bePackagePrivate() + .allowEmptyShould(true) + .check(classes); + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java new file mode 100644 index 0000000..32f808c --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java @@ -0,0 +1,108 @@ +package it.aboutbits.archunit.toolbox.rule.common; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaAnnotation; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +public interface ControllerRequestMappingsMustBeSecurityTested { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void controller_methods_with_request_mapping_must_be_security_tested(JavaClasses classes) { + methods() + .that() + .areDeclaredInClassesThat( + new DescribedPredicate<>("are annotated with @Controller or @RestController") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.isAnnotatedWith("org.springframework.stereotype.Controller") + || javaClass.isAnnotatedWith( + "org.springframework.web.bind.annotation.RestController"); + } + } + ) + .and() + .areAnnotatedWith(new DescribedPredicate<>("a RequestMapping annotation") { + @Override + public boolean test(JavaAnnotation javaAnnotation) { + var rawJavaAnnotation = javaAnnotation.getRawType(); + + return rawJavaAnnotation.isAssignableTo("org.springframework.web.bind.annotation.RequestMapping") + // The (Get|Post|Put|Delete|Patch)Mapping annotations are annotated with @RequestMapping + || rawJavaAnnotation.isAnnotatedWith( + "org.springframework.web.bind.annotation.RequestMapping" + ); + } + }) + .should(new BeSecurityTested()) + .check(classes); + } + + class BeSecurityTested extends ArchCondition { + public BeSecurityTested() { + super("be security tested"); + } + + @Override + public void check(JavaMethod method, ConditionEvents events) { + var controllerClass = method.getOwner(); + var controllerClassName = controllerClass.getFullName(); + var securityTestClassName = controllerClassName + "SecurityTest"; + var methodName = method.getName(); + var expectedNestedMethodClassName = Character.toUpperCase(methodName.charAt(0)) + + methodName.substring(1); + + JavaClass securityTestClass; + try { + securityTestClass = controllerClass + .getPackage() + .getClassWithFullyQualifiedName(securityTestClassName); + } catch (IllegalArgumentException _) { + events.add(SimpleConditionEvent.violated( + method, + String.format( + "Method %s in class %s%nis annotated with @RequestMapping but the corresponding SecurityTest class %s is missing", + method.getFullName(), + controllerClass.getFullName(), + securityTestClassName + ) + )); + + return; + } + + var nestedMethodTestClassFound = securityTestClass.getPackage() + .getClasses() + .stream() + .anyMatch(clazz -> clazz.getName() + .startsWith("%s$%s".formatted( + securityTestClass.getName(), + expectedNestedMethodClassName + )) + && clazz.isAnnotatedWith(org.junit.jupiter.api.Nested.class) + && !clazz.isAnnotatedWith(com.tngtech.archunit.junit.ArchIgnore.class) + && !clazz.isAnnotatedWith(it.aboutbits.archunit.toolbox.support.ArchIgnoreGroupName.class) + ); + + if (!nestedMethodTestClassFound) { + events.add(SimpleConditionEvent.violated( + method, + String.format( + "Method %s%nis annotated with @RequestMapping, and SecurityTest class %s exists, but it does not contain a @Nested test class named %s (%s.java:0)", + method.getFullName(), + securityTestClass.getFullName(), + expectedNestedMethodClassName, + securityTestClass.getSimpleName() + ) + )); + } + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java new file mode 100644 index 0000000..66651a7 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java @@ -0,0 +1,195 @@ +package it.aboutbits.archunit.toolbox.rule.common; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaParameterizedType; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +public interface SortMappingsExhaustiveArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void sort_mappings_cover_all_sort_enum_values(JavaClasses classes) { + classes() + .that() + .areAnnotatedWith("it.aboutbits.springboot.toolbox.stereotype.Store") + .should(new HaveExhaustiveSortMappingsIfPresent()) + .allowEmptyShould(true) + .check(classes); + } + + @Slf4j + class HaveExhaustiveSortMappingsIfPresent extends ArchCondition { + public HaveExhaustiveSortMappingsIfPresent() { + super("have SortMappings that map all values of the associated Sort enum"); + } + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + var mappings = detectSortMappings(javaClass); + + if (mappings.fieldToKeyNames().isEmpty()) { + return; + } + + validateSortMappings(javaClass, events, mappings); + } + + private static @NonNull DetectedSortField detectSortMappings(JavaClass javaClass) { + // Read SortMappings static fields, resolve their enum type, and collect key names + var fieldToKeyNames = new HashMap>(); + var fieldToEnumClassName = new HashMap(); + try { + var runtimeClass = Class.forName(javaClass.getFullName()); + for (var field : javaClass.getFields()) { + if (field.getRawType().getFullName().equals( + "it.aboutbits.springboot.toolbox.persistence.SortMappings")) { + String enumClassName = null; + + // Try to resolve enum type from the field's generic type parameter + var fieldType = field.getType(); + if (fieldType instanceof JavaParameterizedType pt && !pt.getActualTypeArguments() + .isEmpty()) { + enumClassName = pt.getActualTypeArguments() + .getFirst() + .toErasure() + .getFullName(); + } + + try { + var reflectField = runtimeClass.getDeclaredField(field.getName()); + reflectField.setAccessible(true); + var value = reflectField.get(null); + if (value instanceof Map m) { + var keys = m.keySet(); + var keyNames = keys.stream() + .filter(k -> k instanceof Enum) + .map(k -> ((Enum) k).name()) + .collect(Collectors.toSet()); + fieldToKeyNames.put(field.getName(), keyNames); + + // If generic info is missing, infer enum type from the first key + if (enumClassName == null) { + var anyKey = keys.stream() + .filter(k -> k instanceof Enum) + .findFirst(); + if (anyKey.isPresent()) { + enumClassName = ((Enum) anyKey.get()).getDeclaringClass() + .getName(); + } + } + } + } catch (Exception _) { + // ignore fields we cannot read (non-static or other issues) + log.warn( + "Failed to read SortMappings field {} in {} ({}.java:{})", + field.getName(), + javaClass.getFullName(), + javaClass.getSimpleName(), + field.getSourceCodeLocation().getLineNumber() + ); + } + + if (enumClassName != null) { + fieldToEnumClassName.put(field.getName(), enumClassName); + } + } + } + } catch (ClassNotFoundException _) { + // ignore + log.warn( + "Failed to resolve enum type for SortMappings field in {} ({}.java:{})", + javaClass.getFullName(), + javaClass.getSimpleName(), + javaClass.getSourceCodeLocation().getLineNumber() + ); + } + return new DetectedSortField(fieldToKeyNames, fieldToEnumClassName); + } + + private static void validateSortMappings( + JavaClass javaClass, + ConditionEvents events, + DetectedSortField mappings + ) { + // Validate each SortMappings field against its associated enum + for (var entry : mappings.fieldToKeyNames().entrySet()) { + var fieldName = entry.getKey(); + var keyNames = entry.getValue(); + var enumClassName = mappings.fieldToEnumClassName().get(fieldName); + + if (enumClassName == null) { + // Cannot determine enum type for this field; skip validation + log.warn( + "Cannot determine enum type for SortMappings field {} in {} ({}.java:{})", + fieldName, + javaClass.getFullName(), + javaClass.getSimpleName(), + javaClass.getSourceCodeLocation().getLineNumber() + ); + continue; + } + + try { + var enumClass = Class.forName(enumClassName); + if (enumClass.isEnum()) { + var enumConstants = (Enum[]) enumClass.getEnumConstants(); + var missing = Stream.of(enumConstants) + .map(Enum::name) + .filter(name -> !keyNames.contains(name)) + .toList(); + if (!missing.isEmpty()) { + // Try to get a precise field line number for the message + var fieldLine = javaClass.getFields().stream() + .filter(f -> f.getName().equals(fieldName)) + .filter(f -> f.getRawType() + .getFullName() + .equals("it.aboutbits.springboot.toolbox.persistence.SortMappings")) + .findFirst() + .map(f -> f.getSourceCodeLocation().getLineNumber()) + .orElse(-1); + + var message = String.format( + "Class %s: SortMappings field %s is missing mappings for enum %s values %s (%s.java:%d)", + javaClass.getFullName(), + fieldName, + enumClass.getSimpleName(), + missing, + javaClass.getSimpleName(), + fieldLine + ); + events.add(SimpleConditionEvent.violated(javaClass, message)); + } + } + } catch (ClassNotFoundException _) { + // ignore + log.warn( + "Failed to resolve enum type for SortMappings field {} in {} ({}.java:{})", + fieldName, + javaClass.getFullName(), + javaClass.getSimpleName(), + javaClass.getSourceCodeLocation().getLineNumber() + ); + } + } + } + + private record DetectedSortField( + HashMap> fieldToKeyNames, + HashMap fieldToEnumClassName + ) { + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java b/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java new file mode 100644 index 0000000..7e4d2a0 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java @@ -0,0 +1,159 @@ +package it.aboutbits.archunit.toolbox.util; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaCodeUnit; +import com.tngtech.archunit.core.domain.JavaConstructor; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.JavaParameterizedType; +import com.tngtech.archunit.core.domain.JavaStaticInitializer; +import org.jspecify.annotations.NullMarked; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Stream; + +@NullMarked +public final class LineNumberUtil { + private LineNumberUtil() { + } + + public static List getDependencyUsageLineNumberTypes( + JavaClass originClass, + JavaClass targetClass + ) { + var originClassConstructors = originClass.getConstructors(); + + // Check the usage of the target class in the fields of the origin class + var javaFieldLines = originClass.getFields() + .stream() + .filter(field -> field.getType().equals(targetClass)) + .map(field -> LineNumberType.of( + field.getSourceCodeLocation().getLineNumber() == 0 + ? originClassConstructors.iterator().next().getSourceCodeLocation().getLineNumber() + : field.getSourceCodeLocation().getLineNumber(), + "Field" + )); + + // Check the usage of the target class in the static initializers of the origin class + var javaStaticInitializerLines = originClass.getStaticInitializer() + .map(javaStaticInitializer -> getDependencyJavaCodeUnitLineNumbers( + Set.of(javaStaticInitializer), + targetClass + )) + .orElse(Stream.empty()); + + // Check the usage of target class in the constructors of the origin class + var javaConstructorLines = getDependencyJavaCodeUnitLineNumbers( + originClassConstructors, + targetClass + ); + + // Check the usage of target class in the methods of the origin class + var javaMethodLines = getDependencyJavaCodeUnitLineNumbers( + originClass.getMethods(), + targetClass + ); + + var lines = Stream.of( + javaFieldLines, + javaStaticInitializerLines, + javaConstructorLines, + javaMethodLines + ).flatMap(Function.identity()).toList(); + + if (!lines.isEmpty()) { + return lines; + } + + return List.of(LineNumberType.of(0, "Unknown Usage Type Fix Me")); + } + + private static Stream getDependencyJavaCodeUnitLineNumbers( + Set codeUnits, + JavaClass targetClass + ) { + return codeUnits.stream() + .map(codeUnit -> { + var codeUnitType = switch (codeUnit) { + case JavaMethod _ -> "Method"; + case JavaConstructor _ -> "Constructor"; + case JavaStaticInitializer _ -> "Static Initializer"; + default -> "Unknown Code Unit Type Fix Me"; + }; + + // Check if the Method/Constructor has a parameter of the target class + var parameterPresent = codeUnit.getParameters() + .stream() + .anyMatch(parameter -> parameter.getType().equals(targetClass)); + + // Check if the Method/Constructor has a type parameter of the target class + var typeVariablePresent = codeUnit.getTypeParameters() + .stream() + .anyMatch(parameter -> parameter.toErasure().equals(targetClass)); + + // Check if the Method/Constructor returns the target class + var returnType = codeUnit.getReturnType(); + var actualReturnType = returnType instanceof JavaParameterizedType pReturnType + ? pReturnType.getActualTypeArguments().getFirst() + : returnType; + var returnTypePresent = actualReturnType.equals(targetClass); + + // Check if the Method/Constructor calls a constructor of the target class + var javaConstructorCallLines = codeUnit + .getConstructorCallsFromSelf() + .stream() + .filter(constructorCall -> constructorCall.getTargetOwner().equals(targetClass)) + .map(constructorCall -> LineNumberType.of( + constructorCall.getSourceCodeLocation().getLineNumber(), + "Constructor Call" + )); + + // Check if the Method/Constructor calls a method of the target class + var javaMethodCallLines = codeUnit + .getMethodCallsFromSelf() + .stream() + .filter(methodCall -> methodCall.getTargetOwner().equals(targetClass)) + .map(methodCall -> LineNumberType.of( + methodCall.getSourceCodeLocation().getLineNumber(), + "Method Call" + )); + + return Stream.of( + parameterPresent + ? Stream.of( + LineNumberType.of( + codeUnit.getSourceCodeLocation().getLineNumber(), + codeUnitType + " Parameter" + )) + : Stream.empty(), + typeVariablePresent + ? Stream.of( + LineNumberType.of( + codeUnit.getSourceCodeLocation().getLineNumber(), + codeUnitType + " Generic Parameter" + )) + : Stream.empty(), + returnTypePresent + ? Stream.of( + LineNumberType.of( + codeUnit.getSourceCodeLocation().getLineNumber(), + "Method Return Type" + )) + : Stream.empty(), + javaConstructorCallLines, + javaMethodCallLines + ).flatMap(Function.identity()).toList(); + }) + .flatMap(List::stream); + } + + public record LineNumberType( + int lineNumber, + String type + ) { + public static LineNumberType of(int lineNumber, String type) { + return new LineNumberType(lineNumber, type); + } + } +} diff --git a/src/test/java/it/aboutbits/archunit/toolbox/ArchitectureTest.java b/src/test/java/it/aboutbits/archunit/toolbox/ArchitectureTest.java index 8bd458d..5acf9c7 100644 --- a/src/test/java/it/aboutbits/archunit/toolbox/ArchitectureTest.java +++ b/src/test/java/it/aboutbits/archunit/toolbox/ArchitectureTest.java @@ -4,11 +4,12 @@ import com.tngtech.archunit.junit.CacheMode; import org.jspecify.annotations.NullMarked; +@SuppressWarnings("checkstyle:HideUtilityClassConstructor") @AnalyzeClasses( packages = ArchitectureTest.PACKAGE, cacheMode = CacheMode.PER_CLASS ) @NullMarked -class ArchitectureTest extends ArchitectureTestBase { +class ArchitectureTest implements BaseArchRuleCollection { static final String PACKAGE = "it.aboutbits.archunit.toolbox"; } From 44b082921ca16eaa10139ba4859dc75e879f345f Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Thu, 30 Apr 2026 16:26:12 +0200 Subject: [PATCH 04/10] update deps --- pom.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 4915632..e2e1955 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.1 + 4.0.6 @@ -17,8 +17,8 @@ 25 - 2.45.0 - 0.12.14 + 2.49.0 + 0.13.4 @@ -38,18 +38,18 @@ org.junit.jupiter junit-jupiter-params - 5.14.2 + 5.14.4 com.tngtech.archunit archunit-junit5 - 1.4.1 + 1.4.2 com.tngtech.archunit archunit-junit5-api - 1.4.1 + 1.4.2 compile @@ -59,7 +59,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.14.1 + 3.15.0 ${java.version} ${java.version} @@ -125,7 +125,7 @@ com.puppycrawl.tools checkstyle - 12.3.0 + 13.4.1 it.aboutbits From 4a0206ef4ff2c8b4e49573e49e1de80c9b1c9b39 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Thu, 30 Apr 2026 16:26:39 +0200 Subject: [PATCH 05/10] update rules and add new --- .../toolbox/BaseArchRuleCollection.java | 4 + .../toolbox/CommonArchRuleCollection.java | 2 + .../rule/base/BlacklistMethodsArchRule.java | 8 +- .../rule/base/NoSystemOutOrErrArchRule.java | 82 +++++++++++++++++++ ...tiesMustBeAccessedViaAccessorArchRule.java | 48 +++++++++++ ...erRequestMappingsMustBeSecurityTested.java | 2 + .../SortMappingsExhaustiveArchRule.java | 2 + 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java create mode 100644 src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java diff --git a/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java b/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java index be44859..8d9c61a 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java @@ -4,6 +4,8 @@ import it.aboutbits.archunit.toolbox.rule.base.BlacklistClassesArchRule; import it.aboutbits.archunit.toolbox.rule.base.BlacklistMethodsArchRule; import it.aboutbits.archunit.toolbox.rule.base.EnforceJspecifyArchRule; +import it.aboutbits.archunit.toolbox.rule.base.NoSystemOutOrErrArchRule; +import it.aboutbits.archunit.toolbox.rule.base.RecordPropertiesMustBeAccessedViaAccessorArchRule; import it.aboutbits.archunit.toolbox.rule.base.TestClassInCorrectPackageArchRule; import it.aboutbits.archunit.toolbox.rule.base.TestClassVisibilityArchRule; import it.aboutbits.archunit.toolbox.rule.base.TestMethodVisibilityArchRule; @@ -17,6 +19,8 @@ public interface BaseArchRuleCollection extends BlacklistClassesArchRule, BlacklistMethodsArchRule, EnforceJspecifyArchRule, + NoSystemOutOrErrArchRule, + RecordPropertiesMustBeAccessedViaAccessorArchRule, TestClassInCorrectPackageArchRule, TestClassVisibilityArchRule, TestMethodVisibilityArchRule, diff --git a/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java b/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java index 906b8b3..fd8e858 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java @@ -2,7 +2,9 @@ import it.aboutbits.archunit.toolbox.rule.common.ControllerRequestMappingsMustBeSecurityTested; import it.aboutbits.archunit.toolbox.rule.common.SortMappingsExhaustiveArchRule; +import org.jspecify.annotations.NullMarked; +@NullMarked public interface CommonArchRuleCollection extends ControllerRequestMappingsMustBeSecurityTested, SortMappingsExhaustiveArchRule { diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java index 01bf199..1e31695 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java @@ -21,6 +21,9 @@ public interface BlacklistMethodsArchRule { Set.of( // We should use `assertThatExceptionOfType(...).isThrownBy(...)` instead of `assertThatThrownBy(...)` "org.assertj.core.api.Assertions.assertThatThrownBy", + "org.assertj.core.api.Assertions.assertThrows", + "org.assertj.core.api.Assertions.assertThrowsExactly", + "org.assertj.core.api.Assertions.assertDoesNotThrow", "org.junit.jupiter.api.Assertions.assertThrows", "org.junit.jupiter.api.Assertions.assertDoesNotThrow", // assertThat (allowed is only org.assertj.core.api.Assertions.assertThat) @@ -54,7 +57,10 @@ public interface BlacklistMethodsArchRule { "org.junit.jupiter.api.Assertions.assertNotNull", "org.junit.jupiter.api.Assertions.assertNull", "org.junit.jupiter.api.Assertions.assertTrue", - "org.testcontainers.shaded.org.hamcrest.MatcherAssert.assertThat" + "org.testcontainers.shaded.org.hamcrest.MatcherAssert.assertThat", + // use regular Mockito instead of BDDMockito + "org.mockito.BDDMockito.given", + "org.mockito.BDDMockito.then" ) ); diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java new file mode 100644 index 0000000..361f55a --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java @@ -0,0 +1,82 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface NoSystemOutOrErrArchRule { + @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) + @ArchTest + default void no_system_out_or_err_is_used(JavaClasses classes) { + classes() + .should(new NotUseSystemOutOrErr()) + .check(classes); + } + + class NotUseSystemOutOrErr extends ArchCondition { + private static final String SYSTEM_CLASS = "java.lang.System"; + private static final String FIELD_OUT = "out"; + private static final String FIELD_ERR = "err"; + + public NotUseSystemOutOrErr() { + super("not use System.out or System.err"); + } + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + checkCodeUnits(javaClass, events); + javaClass.getStaticInitializer().ifPresent(staticInitializer -> { + for (var fieldAccess : staticInitializer.getFieldAccesses()) { + if (isSystemOutOrErr( + fieldAccess.getTargetOwner().getFullName(), + fieldAccess.getTarget().getName() + )) { + var message = String.format( + "Static initializer in %s accesses %s.%s (%s.java:%d)", + javaClass.getFullName(), + SYSTEM_CLASS, + fieldAccess.getTarget().getName(), + javaClass.getSimpleName(), + fieldAccess.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(staticInitializer, message)); + } + } + }); + } + + private void checkCodeUnits(JavaClass javaClass, ConditionEvents events) { + for (var method : javaClass.getMethods()) { + for (var fieldAccess : method.getFieldAccesses()) { + if (isSystemOutOrErr( + fieldAccess.getTargetOwner().getFullName(), + fieldAccess.getTarget().getName() + )) { + var message = String.format( + "Method %s accesses %s.%s (%s.java:%d)", + method.getFullName(), + SYSTEM_CLASS, + fieldAccess.getTarget().getName(), + javaClass.getSimpleName(), + fieldAccess.getSourceCodeLocation().getLineNumber() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } + } + } + + private boolean isSystemOutOrErr(String ownerFullName, String fieldName) { + return SYSTEM_CLASS.equals(ownerFullName) + && (FIELD_OUT.equals(fieldName) || FIELD_ERR.equals(fieldName)); + } + } +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java new file mode 100644 index 0000000..e82de09 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java @@ -0,0 +1,48 @@ +package it.aboutbits.archunit.toolbox.rule.base; + +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; + +@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) +@NullMarked +public interface RecordPropertiesMustBeAccessedViaAccessorArchRule { + String ARCH_ALLOW_DIRECT_ACCESS = "it.aboutbits.springboot.toolbox.archunit.ArchAllowDirectAccess"; + + @SuppressWarnings({"unused", "checkstyle:ConstantName", "java:S115"}) + @ArchTest + ArchRule record_properties_must_be_accessed_via_accessor = fields() + .that().areDeclaredInClassesThat().areRecords() + .and().areNotStatic() + .should(new ArchCondition<>("only be accessed by the record itself") { + @Override + public void check(JavaField field, ConditionEvents events) { + if (field.isAnnotatedWith(ARCH_ALLOW_DIRECT_ACCESS) + || field.getOwner().isAnnotatedWith(ARCH_ALLOW_DIRECT_ACCESS)) { + return; + } + + for (var access : field.getAccessesToSelf()) { + // Check if the origin of the access is NOT the record class that owns the field + if (!access.getOrigin().getOwner().equals(field.getOwner())) { + var message = "Record property [%s] in [%s] accessed directly by [%s]. Use accessor method [%s()] instead. (%s.java:%d)" + .formatted( + field.getName(), + field.getOwner().getSimpleName(), + access.getOrigin().getFullName(), + field.getName(), + access.getOrigin().getOwner().getSimpleName(), + access.getLineNumber() + ); + events.add(SimpleConditionEvent.violated(access, message)); + } + } + } + }); +} diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java index 32f808c..abbaf9f 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java @@ -9,9 +9,11 @@ import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.jspecify.annotations.NullMarked; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +@NullMarked public interface ControllerRequestMappingsMustBeSecurityTested { @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) @ArchTest diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java index 66651a7..9dc28a5 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java @@ -9,6 +9,7 @@ import com.tngtech.archunit.lang.SimpleConditionEvent; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import java.util.HashMap; import java.util.Map; @@ -18,6 +19,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +@NullMarked public interface SortMappingsExhaustiveArchRule { @SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"}) @ArchTest From dd9756883f443b356ab61f7122587fe0453d7bb0 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 17:39:49 +0200 Subject: [PATCH 06/10] remove version tag from junit-jupiter-params dependency --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index e2e1955..aac5f3d 100644 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,6 @@ org.junit.jupiter junit-jupiter-params - 5.14.4 From 67b1df96e06aafce357940f0ecd44cfc3c6ef362 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 17:41:01 +0200 Subject: [PATCH 07/10] fix check to work for all classes extending SortMappings --- .../rule/common/SortMappingsExhaustiveArchRule.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java index 9dc28a5..aba39aa 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java @@ -8,7 +8,6 @@ import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; import java.util.HashMap; @@ -49,14 +48,14 @@ public void check(JavaClass javaClass, ConditionEvents events) { validateSortMappings(javaClass, events, mappings); } - private static @NonNull DetectedSortField detectSortMappings(JavaClass javaClass) { + private static DetectedSortField detectSortMappings(JavaClass javaClass) { // Read SortMappings static fields, resolve their enum type, and collect key names var fieldToKeyNames = new HashMap>(); var fieldToEnumClassName = new HashMap(); try { var runtimeClass = Class.forName(javaClass.getFullName()); for (var field : javaClass.getFields()) { - if (field.getRawType().getFullName().equals( + if (field.getRawType().isAssignableTo( "it.aboutbits.springboot.toolbox.persistence.SortMappings")) { String enumClassName = null; @@ -157,8 +156,7 @@ private static void validateSortMappings( var fieldLine = javaClass.getFields().stream() .filter(f -> f.getName().equals(fieldName)) .filter(f -> f.getRawType() - .getFullName() - .equals("it.aboutbits.springboot.toolbox.persistence.SortMappings")) + .isAssignableTo("it.aboutbits.springboot.toolbox.persistence.SortMappings")) .findFirst() .map(f -> f.getSourceCodeLocation().getLineNumber()) .orElse(-1); From 3f1c49a54f217e1546be34a824c2a9be5e2ef6a3 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Thu, 7 May 2026 09:08:02 +0200 Subject: [PATCH 08/10] add utility methods to fetch line numbers from source code elements --- .../archunit/toolbox/util/LineNumberUtil.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java b/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java index 7e4d2a0..db56036 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java @@ -6,9 +6,11 @@ import com.tngtech.archunit.core.domain.JavaMethod; import com.tngtech.archunit.core.domain.JavaParameterizedType; import com.tngtech.archunit.core.domain.JavaStaticInitializer; +import com.tngtech.archunit.core.domain.properties.HasSourceCodeLocation; import org.jspecify.annotations.NullMarked; import java.util.List; +import java.util.NoSuchElementException; import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -18,6 +20,23 @@ public final class LineNumberUtil { private LineNumberUtil() { } + public static int getLineNumber(HasSourceCodeLocation element) { + return element.getSourceCodeLocation().getLineNumber(); + } + + // JavaClass overload with constructor fallback: class-level SLOC is 0 for nested/anonymous classes + public static int getLineNumber(JavaClass javaClass) { + var lineNumber = javaClass.getSourceCodeLocation().getLineNumber(); + if (lineNumber != 0) { + return lineNumber; + } + try { + return javaClass.getConstructors().iterator().next().getSourceCodeLocation().getLineNumber(); + } catch (NoSuchElementException _) { + return 0; + } + } + public static List getDependencyUsageLineNumberTypes( JavaClass originClass, JavaClass targetClass From 96d4efab7589b3547239739c97d6f04c4ba68a79 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Thu, 7 May 2026 09:37:36 +0200 Subject: [PATCH 09/10] replace direct line number fetching with `LineNumberUtil.getLineNumber` --- .../base/BlacklistAnnotationsArchRule.java | 9 +++--- .../rule/base/BlacklistMethodsArchRule.java | 5 ++-- .../rule/base/NoSystemOutOrErrArchRule.java | 5 ++-- ...tiesMustBeAccessedViaAccessorArchRule.java | 3 +- .../TestNestedClassMatchNameArchRule.java | 28 ++----------------- .../SortMappingsExhaustiveArchRule.java | 12 ++++---- 6 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java index 99757df..f44afb7 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java @@ -12,6 +12,7 @@ import java.util.Set; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) @NullMarked @@ -80,7 +81,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { javaClass.getFullName(), annotation.getRawType().getFullName(), javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() + getLineNumber(javaClass) ); events.add(SimpleConditionEvent.violated(javaClass, message)); } @@ -96,7 +97,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { method.getFullName(), annotation.getRawType().getFullName(), javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() + getLineNumber(method) ); events.add(SimpleConditionEvent.violated(method, message)); } @@ -111,7 +112,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { method.getFullName(), annotation.getRawType().getFullName(), javaClass.getSimpleName(), - method.getSourceCodeLocation().getLineNumber() + getLineNumber(method) ); // Parameter doesn't have its own SLOC, use method's events.add(SimpleConditionEvent.violated(parameter, message)); } @@ -129,7 +130,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { javaClass.getFullName(), annotation.getRawType().getFullName(), javaClass.getSimpleName(), - field.getSourceCodeLocation().getLineNumber() + getLineNumber(field) ); events.add(SimpleConditionEvent.violated(field, message)); } diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java index 1e31695..863e875 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java @@ -12,6 +12,7 @@ import java.util.Set; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) @NullMarked @@ -93,7 +94,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { method.getFullName(), fullMethodName, javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() + getLineNumber(methodCall) ); events.add(SimpleConditionEvent.violated(method, message)); } @@ -114,7 +115,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { javaClass.getFullName(), fullMethodName, javaClass.getSimpleName(), - methodCall.getSourceCodeLocation().getLineNumber() + getLineNumber(methodCall) ); events.add(SimpleConditionEvent.violated(staticInitializer, message)); } diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java index 361f55a..79b99f4 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java @@ -9,6 +9,7 @@ import org.jspecify.annotations.NullMarked; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) @NullMarked @@ -45,7 +46,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { SYSTEM_CLASS, fieldAccess.getTarget().getName(), javaClass.getSimpleName(), - fieldAccess.getSourceCodeLocation().getLineNumber() + getLineNumber(fieldAccess) ); events.add(SimpleConditionEvent.violated(staticInitializer, message)); } @@ -66,7 +67,7 @@ private void checkCodeUnits(JavaClass javaClass, ConditionEvents events) { SYSTEM_CLASS, fieldAccess.getTarget().getName(), javaClass.getSimpleName(), - fieldAccess.getSourceCodeLocation().getLineNumber() + getLineNumber(fieldAccess) ); events.add(SimpleConditionEvent.violated(method, message)); } diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java index e82de09..b7e56e3 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java @@ -9,6 +9,7 @@ import org.jspecify.annotations.NullMarked; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) @NullMarked @@ -38,7 +39,7 @@ public void check(JavaField field, ConditionEvents events) { access.getOrigin().getFullName(), field.getName(), access.getOrigin().getOwner().getSimpleName(), - access.getLineNumber() + getLineNumber(access) ); events.add(SimpleConditionEvent.violated(access, message)); } diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java index f847d16..16cbb1c 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java @@ -8,12 +8,12 @@ import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import org.jspecify.annotations.NullMarked; -import org.slf4j.LoggerFactory; import java.util.stream.Collectors; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static it.aboutbits.archunit.toolbox.config.ArchRuleConfig.TEST_CLASS_SUFFIXES; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"}) @NullMarked @@ -92,11 +92,7 @@ public void check(JavaClass testClass, ConditionEvents events) { var nestedClassBaseClassSimpleName = nestedClass.getName() .replace(nestedClass.getPackageName() + ".", "") .replaceAll("\\$.+", ""); - var nestedClassLineNumber = nestedClass.getConstructors() - .iterator() - .next() - .getSourceCodeLocation() - .getLineNumber(); + var nestedClassLineNumber = getLineNumber(nestedClass); /* * This is only true for inner @Nested group classes like for example @@ -145,25 +141,7 @@ public void check(JavaClass testClass, ConditionEvents events) { .anyMatch(methodName -> methodName.equals(expectedMethodName)); if (!methodExists) { - int productionClassLineNumber = -1; - - try { - productionClassLineNumber = productionClass.getConstructors() - .iterator() - .next() - .getSourceCodeLocation() - .getLineNumber(); - } catch (Exception _) { - var log = LoggerFactory.getLogger(getClass()); - log.error( - "Failed to resolve productionClassLineNumber. [nestedClass.getName()={}, nestedClassBaseClassSimpleName={}, nestedClassLineNumber={}, expectedMethodName={}, productionClass.getName()={}]", - nestedClass.getName(), - nestedClassBaseClassSimpleName, - nestedClassLineNumber, - expectedMethodName, - productionClass.getName() - ); - } + var productionClassLineNumber = getLineNumber(productionClass); var message = "The @Nested test class <%s> (%s.java:%s)%ndoes not match any expected method name <%s> in production class <%s> (%s.java:%s)".formatted( nestedClass.getName(), diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java index aba39aa..1349b2e 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java @@ -7,6 +7,7 @@ import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; +import it.aboutbits.archunit.toolbox.util.LineNumberUtil; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NullMarked; @@ -17,6 +18,7 @@ import java.util.stream.Stream; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; @NullMarked public interface SortMappingsExhaustiveArchRule { @@ -99,7 +101,7 @@ private static DetectedSortField detectSortMappings(JavaClass javaClass) { field.getName(), javaClass.getFullName(), javaClass.getSimpleName(), - field.getSourceCodeLocation().getLineNumber() + getLineNumber(field) ); } @@ -114,7 +116,7 @@ private static DetectedSortField detectSortMappings(JavaClass javaClass) { "Failed to resolve enum type for SortMappings field in {} ({}.java:{})", javaClass.getFullName(), javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() + getLineNumber(javaClass) ); } return new DetectedSortField(fieldToKeyNames, fieldToEnumClassName); @@ -138,7 +140,7 @@ private static void validateSortMappings( fieldName, javaClass.getFullName(), javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() + getLineNumber(javaClass) ); continue; } @@ -158,7 +160,7 @@ private static void validateSortMappings( .filter(f -> f.getRawType() .isAssignableTo("it.aboutbits.springboot.toolbox.persistence.SortMappings")) .findFirst() - .map(f -> f.getSourceCodeLocation().getLineNumber()) + .map(LineNumberUtil::getLineNumber) .orElse(-1); var message = String.format( @@ -180,7 +182,7 @@ private static void validateSortMappings( fieldName, javaClass.getFullName(), javaClass.getSimpleName(), - javaClass.getSourceCodeLocation().getLineNumber() + getLineNumber(javaClass) ); } } From f951ffe2ae6927d1e3254b62c205c3bfdbba30ec Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Thu, 7 May 2026 10:45:57 +0200 Subject: [PATCH 10/10] update ArchTest annotation to use fully qualified path --- .../toolbox/rule/base/TestMethodVisibilityArchRule.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java index 851d145..5b16356 100644 --- a/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestMethodVisibilityArchRule.java @@ -22,7 +22,7 @@ default void test_methods_must_be_package_private(JavaClasses classes) { .or() .areAnnotatedWith(org.junit.jupiter.params.ParameterizedTest.class) .or() - .areAnnotatedWith(ArchTest.class) + .areAnnotatedWith(com.tngtech.archunit.junit.ArchTest.class) .and() .areDeclaredInClassesThat(new DescribedPredicate<>("are not an interface") { @Override