diff --git a/pom.xml b/pom.xml index e477f28..aac5f3d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,23 +1,24 @@ - + 4.0.0 org.springframework.boot spring-boot-starter-parent - 4.0.1 + 4.0.6 it.aboutbits archunit-toolbox - 1.1.0 + 1.2.0-RC1 Common ArchUnit tooling for Java / Spring Boot projects. 25 - 2.45.0 - 0.12.14 + 2.49.0 + 0.13.4 @@ -37,18 +38,17 @@ org.junit.jupiter junit-jupiter-params - 5.14.2 com.tngtech.archunit archunit-junit5 - 1.4.1 + 1.4.2 com.tngtech.archunit archunit-junit5-api - 1.4.1 + 1.4.2 compile @@ -58,7 +58,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.14.1 + 3.15.0 ${java.version} ${java.version} @@ -124,7 +124,7 @@ com.puppycrawl.tools checkstyle - 12.3.0 + 13.4.1 it.aboutbits 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 b8eb625..0000000 --- a/src/main/java/it/aboutbits/archunit/toolbox/ArchitectureTestBase.java +++ /dev/null @@ -1,530 +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.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 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 = 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 = 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 = 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); - - /* ****************************************************************** */ - - 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..8d9c61a --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java @@ -0,0 +1,29 @@ +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.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; +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, + NoSystemOutOrErrArchRule, + RecordPropertiesMustBeAccessedViaAccessorArchRule, + 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..fd8e858 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/CommonArchRuleCollection.java @@ -0,0 +1,11 @@ +package it.aboutbits.archunit.toolbox; + +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/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..f44afb7 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java @@ -0,0 +1,141 @@ +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; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; + +@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(), + getLineNumber(javaClass) + ); + 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(), + getLineNumber(method) + ); + 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(), + getLineNumber(method) + ); // 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(), + getLineNumber(field) + ); + 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..863e875 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistMethodsArchRule.java @@ -0,0 +1,126 @@ +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; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; + +@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.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) + "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", + // use regular Mockito instead of BDDMockito + "org.mockito.BDDMockito.given", + "org.mockito.BDDMockito.then" + ) + ); + + @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(), + getLineNumber(methodCall) + ); + 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(), + getLineNumber(methodCall) + ); + 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/NoSystemOutOrErrArchRule.java b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java new file mode 100644 index 0000000..79b99f4 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/NoSystemOutOrErrArchRule.java @@ -0,0 +1,83 @@ +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.util.LineNumberUtil.getLineNumber; + +@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(), + getLineNumber(fieldAccess) + ); + 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(), + getLineNumber(fieldAccess) + ); + 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..b7e56e3 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/RecordPropertiesMustBeAccessedViaAccessorArchRule.java @@ -0,0 +1,49 @@ +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; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; + +@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(), + getLineNumber(access) + ); + events.add(SimpleConditionEvent.violated(access, message)); + } + } + } + }); +} 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..5b16356 --- /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(com.tngtech.archunit.junit.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..16cbb1c --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/base/TestNestedClassMatchNameArchRule.java @@ -0,0 +1,163 @@ +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 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 +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 = getLineNumber(nestedClass); + + /* + * 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) { + 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(), + 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..abbaf9f --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/ControllerRequestMappingsMustBeSecurityTested.java @@ -0,0 +1,110 @@ +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 org.jspecify.annotations.NullMarked; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +@NullMarked +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..1349b2e --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/rule/common/SortMappingsExhaustiveArchRule.java @@ -0,0 +1,197 @@ +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 it.aboutbits.archunit.toolbox.util.LineNumberUtil; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; + +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; +import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber; + +@NullMarked +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 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().isAssignableTo( + "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(), + getLineNumber(field) + ); + } + + 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(), + getLineNumber(javaClass) + ); + } + 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(), + getLineNumber(javaClass) + ); + 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() + .isAssignableTo("it.aboutbits.springboot.toolbox.persistence.SortMappings")) + .findFirst() + .map(LineNumberUtil::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(), + getLineNumber(javaClass) + ); + } + } + } + + 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..db56036 --- /dev/null +++ b/src/main/java/it/aboutbits/archunit/toolbox/util/LineNumberUtil.java @@ -0,0 +1,178 @@ +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 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; + +@NullMarked +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 + ) { + 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"; }