diff --git a/pom.xml b/pom.xml index 8bf8ca0..b73b170 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,19 @@ junit-jupiter test + + com.tngtech.archunit + archunit + 1.4.2 + test + + + + org.slf4j + slf4j-simple + 2.0.18 + test + diff --git a/src/test/java/module-info.java b/src/test/java/module-info.java index a587a77..f2d4976 100644 --- a/src/test/java/module-info.java +++ b/src/test/java/module-info.java @@ -29,4 +29,5 @@ requires org.eclipse.collections.impl; requires org.junit.jupiter.api; requires org.junit.jupiter.params; + requires com.tngtech.archunit; } diff --git a/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java b/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java new file mode 100644 index 0000000..4cb04ed --- /dev/null +++ b/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.assertj.eclipse.collections.api.arch; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage; + +import org.junit.jupiter.api.Test; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; + +public class PublicApiExposedTypesTest { + @Test + void publicApiDoesNotExposeEclipseCollectionsImplTypes() { + JavaClasses importedClasses = new ClassFileImporter().importPackages("org.assertj.eclipse.collections"); + ArchRule rule = classes().that() + .resideInAnyPackage( + "org.assertj.eclipse.collections.api..", + "org.assertj.eclipse.collections.error..", + "org.assertj.eclipse.collections.util.." + ) + .should(notExposeTypesInPackage("org.eclipse.collections.impl.") + .as("not expose types contained in package org.eclipse.collections.impl")); + + rule.check(importedClasses); + } +} diff --git a/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java new file mode 100644 index 0000000..8dea791 --- /dev/null +++ b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java @@ -0,0 +1,146 @@ +/* + * Copyright 2025-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.assertj.eclipse.collections.test; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaCodeUnit; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.domain.JavaParameterizedType; +import com.tngtech.archunit.core.domain.JavaType; +import com.tngtech.archunit.core.domain.properties.HasModifiers; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; + +import java.util.HashSet; +import java.util.Set; + +/** + * An ArchUnit condition that checks if the given package's types are exposed in the public API contract. The public + * contract refers to the following: + * + *

+ *

+ *

+ * + *

These checks include parameterized types/generics.

+ */ +public final class ExposedTypesInPublicApiContractArchCondition extends ArchCondition { + private final String packageName; + + /** + * Entry point for using this condition. + * + * @param packageName The package name to check for exposed types. + * @return An instance of ExposedTypesInPublicApiContractArchCondition. + */ + public static ExposedTypesInPublicApiContractArchCondition notExposeTypesInPackage(String packageName) { + return new ExposedTypesInPublicApiContractArchCondition(packageName, "not expose types contained in package %s", packageName); + } + + private ExposedTypesInPublicApiContractArchCondition(String packageName, String description, Object... args) { + super(description, args); + this.packageName = packageName; + } + + @Override + public void check(JavaClass item, ConditionEvents events) { + checkParentClass(item, events, packageName); + checkInterfaces(item, events, packageName); + checkFields(item, events, packageName); + checkConstructors(item, events, packageName); + checkMethods(item, events, packageName); + } + + private static void checkParentClass(JavaClass javaClass, ConditionEvents events, String packageName) { + javaClass.getRawSuperclass() + .ifPresent(parentClass -> checkType(javaClass, parentClass, events, packageName, "parent class")); + } + + private static void checkInterfaces(JavaClass javaClass, ConditionEvents events, String packageName) { + javaClass.getRawInterfaces().stream() + .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected) + .forEach(interFaceType -> checkType(javaClass, interFaceType, events, packageName, "interface")); + } + + private static void checkFields(JavaClass javaClass, ConditionEvents events, String packageName) { + javaClass.getFields().stream() + .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected) + .forEach(field -> checkType(javaClass, field.getType(), events, packageName, "field " + field.getFullName())); + } + + private static void checkConstructors(JavaClass javaClass, ConditionEvents events, String packageName) { + javaClass.getConstructors().stream() + .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected) + .forEach(constructor -> checkParameterTypes(javaClass, constructor, events, packageName)); + } + + private static void checkMethods(JavaClass javaClass, ConditionEvents events, String packageName) { + javaClass.getMethods().stream() + .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected) + .forEach(method -> { + checkType(javaClass, method.getReturnType(), events, packageName, "return type of " + method.getFullName()); + checkParameterTypes(javaClass, method, events, packageName); + }); + } + + private static void checkParameterTypes(JavaClass owner, JavaCodeUnit codeUnit, ConditionEvents events, String packageName) { + codeUnit.getParameterTypes() + .forEach(parameterType -> checkType(owner, parameterType, events, packageName, "parameter of " + codeUnit.getFullName())); + } + + private static void checkType(JavaClass owner, JavaType type, ConditionEvents events, String packageName, String usageDescription) { + if (isTypeContainedInPackage(type.toErasure(), packageName)) { + events.add(SimpleConditionEvent.violated(owner, "%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription))); + } + recursiveCheckType(owner, type, events, packageName, usageDescription, new HashSet<>()); + } + + /** + * Don't call this method directly. Use {@link #checkType(JavaClass, JavaType, ConditionEvents, String, String)} + * instead. + */ + private static void recursiveCheckType(JavaClass owner, JavaType type, ConditionEvents events, + String packageName, String usageDescription, Set visitedTypes) { + if (!visitedTypes.add(type.getName())) { + return; + } + + if (isTypeContainedInPackage(type.toErasure(), packageName)) { + events.add(SimpleConditionEvent.violated(owner, + "%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription))); + } + + if (type instanceof JavaParameterizedType parameterizedType) { + parameterizedType.getActualTypeArguments() + .forEach(typeArgument -> recursiveCheckType(owner, typeArgument, events, packageName, usageDescription, visitedTypes)); + } + } + + public static boolean isPublicOrProtected(HasModifiers member) { + return member.getModifiers().contains(JavaModifier.PUBLIC) || member.getModifiers().contains(JavaModifier.PROTECTED); + } + + private static boolean isTypeContainedInPackage(JavaClass type, String packageName) { + return type.getFullName().startsWith(packageName); + } +} diff --git a/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java new file mode 100644 index 0000000..4cf9e81 --- /dev/null +++ b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.assertj.eclipse.collections.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ConditionEvents; + +class ExposedTypesInPublicApiContractArchConditionTest { + + private static final String FORBIDDEN_PACKAGE = "java.util.concurrent."; + + @Test + void cleanClassProducesNoViolations() { + assertNoViolations(ValidClass.class); + } + + @Test + void publicFieldWithForbiddenTypeIsFlagged() { + assertHasViolationContaining(ExposesForbiddenPublicField.class, "AtomicInteger"); + } + + @Test + void protectedFieldWithForbiddenTypeIsFlagged() { + assertHasViolationContaining(ExposesForbiddenProtectedField.class, "AtomicInteger"); + } + + @Test + void privateFieldWithForbiddenTypeIsNotFlagged() { + assertNoViolations(ValidPrivateField.class); + } + + @Test + void publicMethodReturnTypeWithForbiddenTypeIsFlagged() { + assertHasViolationContaining(ExposesForbiddenReturnType.class, "AtomicInteger"); + } + + @Test + void publicMethodParameterWithForbiddenTypeIsFlagged() { + assertHasViolationContaining(ExposesForbiddenMethodParameter.class, "AtomicInteger"); + } + + @Test + void publicConstructorParameterWithForbiddenTypeIsFlagged() { + assertHasViolationContaining(ExposesForbiddenConstructorParameter.class, "AtomicInteger"); + } + + @Test + void forbiddenParentClassIsFlagged() { + assertHasViolationContaining(ExposesForbiddenParentClass.class, "AtomicReference"); + } + + @Test + void forbiddenInterfaceIsFlagged() { + assertHasViolationContaining(ExposesForbiddenInterface.class, "Callable"); + } + + @Test + void forbiddenGenericTypeArgumentIsFlagged() { + assertHasViolationContaining(ExposesForbiddenGenericArgumentInField.class, "AtomicInteger"); + } + + @Test + void deeplyNestedForbiddenGenericTypeArgumentIsFlagged() { + assertHasViolationContaining(ExposesDeeplyNestedGenericArgument.class, "AtomicInteger"); + } + + private static List violationsFor(Class fixture) { + JavaClass javaClass = new ClassFileImporter().importClass(fixture); + ConditionEvents events = ConditionEvents.Factory.create(); + notExposeTypesInPackage(FORBIDDEN_PACKAGE).check(javaClass, events); + return events.getViolating().stream() + .flatMap(event -> event.getDescriptionLines().stream()) + .toList(); + } + + private static void assertNoViolations(Class fixture) { + assertThat(violationsFor(fixture)).isEmpty(); + } + + private static void assertHasViolationContaining(Class fixture, String expectedSnippet) { + assertThat(violationsFor(fixture)).anyMatch(line -> line.contains(expectedSnippet)); + } + + @SuppressWarnings("unused") + static class ValidClass { + public String validField; + + public String validMethod(int input) { + return String.valueOf(input); + } + } + + static class ExposesForbiddenPublicField { + @SuppressWarnings("unused") + public AtomicInteger counter; + } + + static class ExposesForbiddenProtectedField { + @SuppressWarnings("unused") + protected AtomicInteger counter; + } + + static class ValidPrivateField { + @SuppressWarnings("unused") + private AtomicInteger counter; + + public int value() { + return counter == null ? 0 : counter.get(); + } + } + + static class ExposesForbiddenReturnType { + @SuppressWarnings("unused") + public AtomicInteger create() { + return new AtomicInteger(); + } + } + + static class ExposesForbiddenMethodParameter { + @SuppressWarnings("unused") + public void consume(AtomicInteger value) { + // Do nothing + } + } + + static class ExposesForbiddenConstructorParameter { + @SuppressWarnings("unused") + public ExposesForbiddenConstructorParameter(AtomicInteger value) { + // Do nothing + } + } + + static class ExposesForbiddenParentClass extends AtomicReference {} + + abstract static class ExposesForbiddenInterface implements Callable {} + + static class ExposesForbiddenGenericArgumentInField { + @SuppressWarnings("unused") + public List items; + } + + static class ExposesDeeplyNestedGenericArgument { + @SuppressWarnings("unused") + public Map>> deeplyNested; + } +}