diff --git a/architecture-validator/docs/user-guide.adoc b/architecture-validator/docs/user-guide.adoc index 5bb1543..94d2440 100644 --- a/architecture-validator/docs/user-guide.adoc +++ b/architecture-validator/docs/user-guide.adoc @@ -103,15 +103,25 @@ The plugin provides ten built-in architecture rules that enforce key Hexagonal A === Rule: `domain_must_only_depend_on_domain_or_jdk_core` Domain model classes (identified by the `domainModel` package pattern) may only depend on other classes inside the `domainModel` packages themselves or on the configured `domainAllowedPackages` JDK allow-list (`java.lang..`, `java.time..`, `java.util..`, and `java.math..` by default). This is an allow-list, not a deny-list: it exhaustively keeps the domain free of adapters, domain services, ports, `commonPackages`, and any third-party or framework dependency (Jackson, JPA, Lombok, Spring, …). +The rule also inspects source annotations before compilation, so source-retained annotations such as Lombok's `@Getter` cannot bypass the allow-list. `domainAllowedPackages` is a plain, user-configurable list — a project may extend it with another JDK package, or shrink it (including removing `java.math..`, or every JDK-core entry) if it wants the domain model held to an even stricter standard. === Rule: `domain_services_must_only_depend_on_domain_core_and_ports` Domain service classes (identified by the `domainServices` package pattern) may depend on the domain model, other domain services, inbound/outbound ports, or `domainAllowedPackages` — but never on adapters or any third-party/framework class. +Source annotations are evaluated against this same allow-list, including annotations with `SOURCE` retention that do not appear in compiled bytecode. The `domainServices` package covers what the accompanying book calls both "application services" (which orchestrate the flow through the hexagon) and "domain services" (which enforce cross-aggregate business rules): in practice both live in the same package and are held to the same architectural constraints, since the distinction is one of intent/semantics rather than package location. === Rule: `ports_must_only_depend_on_domain_or_jdk_core` Port-freedom: inbound and outbound port interfaces may only depend on the domain model, other ports, or `domainAllowedPackages`. A port contract must stay completely framework-free — its *implementation* is free to depend on whatever framework or library it needs, but the port interface itself, and any types it references in its signature, must not. +The same restriction applies to source annotations on port declarations and their members. + +=== Source Annotation Findings and Duplicate Violations +The built-in allow-list rules inspect both compiled dependencies through ArchUnit and annotation usages from the Java source. The source analysis exists because annotations with `SOURCE` retention, such as many Lombok annotations, are removed by compilation and cannot be seen by ArchUnit. + +An ordinary imported type is checked only by ArchUnit. A source-retained annotation is checked only by source analysis. An annotation with `CLASS` or `RUNTIME` retention may be visible to both mechanisms, depending on how ArchUnit models that annotation as a dependency. In that case, the report can show two violations for the same forbidden annotation: one from the relevant ArchUnit rule and one from `source_annotation_dependencies_must_obey_core_allow_lists`. + +This duplication is intentional. Both violations apply the same allow-list policy and identify separate evidence sources: the compiled class and the original Java source. The source finding should not be disabled merely to reduce report counts, because doing so would reopen the gap for source-retained annotations. === Rule: `inbound_ports_must_be_interfaces` / `outbound_ports_must_be_interfaces` Classes in the `inPorts`/`outPorts` packages (excluding `portDataTypePackages`, such as command/result DTOs that legitimately live alongside ports) must be interfaces, not concrete classes. diff --git a/architecture-validator/src/main/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTask.java b/architecture-validator/src/main/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTask.java index 34e44a0..cc435ac 100644 --- a/architecture-validator/src/main/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTask.java +++ b/architecture-validator/src/main/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTask.java @@ -1,5 +1,11 @@ package com.arc_e_tect.gradle.architecture; +import com.sun.source.tree.AnnotationTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.ImportTree; +import com.sun.source.util.JavacTask; +import com.sun.source.util.TreePathScanner; +import com.tngtech.archunit.core.domain.PackageMatcher; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.ConfigurableFileCollection; @@ -35,6 +41,10 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; /** * Generates the built-in hexagonal architecture test, and a bridge suite for any external rule @@ -46,6 +56,7 @@ public abstract class GenerateArchitectureTestsTask extends DefaultTask { private static final String TEMPLATE_PATH = "templates/HexagonalArchitectureTest.java.template"; private static final String GENERATED_PACKAGE = "com.arc_e_tect.gradle.architecture.generated"; private static final String EXTERNAL_SUITE_CLASS_NAME = "ExternalRulePackSuite"; + private static final String SOURCE_DEPENDENCY_TEST_CLASS_NAME = "SourceDependencyValidationTest"; private static final Pattern PACKAGE_SEGMENT = Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*"); /** Creates a new task instance. Instantiated by Gradle infrastructure. */ @@ -283,6 +294,7 @@ public void generate() { Path target = outputRoot.resolve("com/arc_e_tect/gradle/architecture/generated/HexagonalArchitectureTest.java"); Files.createDirectories(target.getParent()); Files.writeString(target, rendered, StandardCharsets.UTF_8); + generateSourceDependencyValidationTest(outputRoot); } generateExternalRulePackSuite(outputRoot); } catch (IOException exception) { @@ -290,6 +302,206 @@ public void generate() { } } + private void generateSourceDependencyValidationTest(Path outputRoot) throws IOException { + List violations = findSourceAnnotationDependencyViolations(); + Path target = outputRoot.resolve( + "com/arc_e_tect/gradle/architecture/generated/" + SOURCE_DEPENDENCY_TEST_CLASS_NAME + ".java"); + Files.createDirectories(target.getParent()); + Files.writeString(target, renderSourceDependencyValidationTest(violations), StandardCharsets.UTF_8); + } + + private List findSourceAnnotationDependencyViolations() throws IOException { + if (!getMainSourceDirectory().isPresent()) { + return List.of(); + } + Path sourceRoot = getMainSourceDirectory().get().getAsFile().toPath(); + if (!Files.isDirectory(sourceRoot)) { + return List.of(); + } + + List sourceFiles; + try (Stream paths = Files.walk(sourceRoot)) { + sourceFiles = paths + .filter(path -> path.toString().endsWith(".java")) + .map(Path::toFile) + .toList(); + } + if (sourceFiles.isEmpty()) { + return List.of(); + } + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new GradleException("Architecture source dependency validation requires a JDK, not a JRE"); + } + + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { + Iterable units = fileManager.getJavaFileObjectsFromFiles(sourceFiles); + JavacTask task = (JavacTask) compiler.getTask(null, fileManager, null, List.of("-proc:none"), null, units); + List violations = new ArrayList<>(); + for (CompilationUnitTree unit : task.parse()) { + String packageName = unit.getPackageName() == null ? "" : unit.getPackageName().toString(); + List policies = policiesFor(packageName); + if (policies.isEmpty()) { + continue; + } + + for (String annotationType : annotationTypes(unit, packageName)) { + for (SourceDependencyPolicy policy : policies) { + if (!residesInAnyPackage(annotationType, policy.allowedPackages())) { + violations.add(new SourceDependencyViolation( + policy.ruleName(), + sourceRoot.relativize(Path.of(unit.getSourceFile().toUri())).toString(), + annotationType, + policy.description())); + } + } + } + } + return violations; + } + } + + private List policiesFor(String packageName) { + List policies = new ArrayList<>(); + if (packageMatchesAny(packageName, getDomainModel().get())) { + policies.add(new SourceDependencyPolicy( + "domain_must_only_depend_on_domain_or_jdk_core", + concat(getDomainModel().get(), getDomainAllowedPackages().get()), + "domain model, or the configured JDK allow-list")); + } + if (packageMatchesAny(packageName, getDomainServices().get())) { + policies.add(new SourceDependencyPolicy( + "domain_services_must_only_depend_on_domain_core_and_ports", + concat(getDomainModel().get(), getDomainServices().get(), getInPorts().get(), getOutPorts().get(), + getDomainAllowedPackages().get()), + "domain model, domain services, ports, or the configured JDK allow-list")); + } + if (packageMatchesAny(packageName, resolveAllPortPatterns())) { + policies.add(new SourceDependencyPolicy( + "ports_must_only_depend_on_domain_or_jdk_core", + concat(getDomainModel().get(), getInPorts().get(), getOutPorts().get(), getDomainAllowedPackages().get()), + "domain model, ports, or the configured JDK allow-list")); + } + return policies; + } + + @SafeVarargs + private static List concat(List... packageLists) { + return Stream.of(packageLists) + .flatMap(Collection::stream) + .distinct() + .toList(); + } + + private static boolean residesInAnyPackage(String typeName, List packagePatterns) { + return packageMatchesAny(packageName(typeName), packagePatterns); + } + + private static boolean packageMatchesAny(String packageName, List packagePatterns) { + return packagePatterns.stream().anyMatch(pattern -> PackageMatcher.of(pattern).matches(packageName)); + } + + private static String packageName(String typeName) { + int lastDot = typeName.lastIndexOf('.'); + return lastDot < 0 ? "" : typeName.substring(0, lastDot); + } + + private static List annotationTypes(CompilationUnitTree unit, String packageName) { + Map explicitImports = new LinkedHashMap<>(); + List wildcardImports = new ArrayList<>(); + for (ImportTree importTree : unit.getImports()) { + if (importTree.isStatic()) { + continue; + } + String importedType = importTree.getQualifiedIdentifier().toString(); + if (importedType.endsWith(".*")) { + wildcardImports.add(importedType.substring(0, importedType.length() - 2)); + } else { + explicitImports.put(importedType.substring(importedType.lastIndexOf('.') + 1), importedType); + } + } + + List annotationTypes = new ArrayList<>(); + new TreePathScanner() { + @Override + public Void visitAnnotation(AnnotationTree annotationTree, Void ignored) { + String annotationType = resolveAnnotationType( + annotationTree.getAnnotationType().toString(), + explicitImports, + wildcardImports); + if (annotationType != null) { + annotationTypes.add(annotationType); + } + return super.visitAnnotation(annotationTree, ignored); + } + }.scan(unit, null); + return annotationTypes; + } + + private static String resolveAnnotationType( + String annotationType, + Map explicitImports, + List wildcardImports + ) { + String explicitImport = explicitImports.get(annotationType); + if (explicitImport != null) { + return explicitImport; + } + if (annotationType.contains(".")) { + String rootType = annotationType.substring(0, annotationType.indexOf('.')); + String rootImport = explicitImports.get(rootType); + return rootImport == null ? annotationType : rootImport + annotationType.substring(rootType.length()); + } + if (!wildcardImports.isEmpty()) { + return wildcardImports.getFirst() + "." + annotationType; + } + return null; + } + + private static String renderSourceDependencyValidationTest(List violations) { + StringBuilder source = new StringBuilder(""" + package com.arc_e_tect.gradle.architecture.generated; + + import org.junit.jupiter.api.Test; + + import static org.junit.jupiter.api.Assertions.fail; + + class SourceDependencyValidationTest { + + @Test + void source_annotation_dependencies_must_obey_core_allow_lists() { + """); + if (violations.isEmpty()) { + source.append(" }\n}\n"); + return source.toString(); + } + + source.append(" fail(\"") + .append(escapeJava(renderViolations(violations))) + .append("\");\n }\n}\n"); + return source.toString(); + } + + private static String renderViolations(List violations) { + return violations.stream() + .map(violation -> violation.ruleName() + ": " + violation.sourceFile() + + " uses source annotation " + violation.annotationType() + + ", which is outside the allowed " + violation.allowedDescription()) + .collect(Collectors.joining("\\n")); + } + + private record SourceDependencyPolicy(String ruleName, List allowedPackages, String description) { + } + + private record SourceDependencyViolation( + String ruleName, + String sourceFile, + String annotationType, + String allowedDescription + ) { + } + private List resolveCoreLayerPatterns() { LinkedHashSet patterns = new LinkedHashSet<>(); patterns.addAll(getDomainModel().get()); diff --git a/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/ArchitectureValidatorIntegrationTest.java b/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/ArchitectureValidatorIntegrationTest.java index e1bf76d..c1bae66 100644 --- a/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/ArchitectureValidatorIntegrationTest.java +++ b/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/ArchitectureValidatorIntegrationTest.java @@ -122,6 +122,22 @@ void shouldFailWhenInboundAdaptersDependOnServiceImplementationsDirectly() throw assertThat(result.getOutput()).contains("adapters_must_not_depend_on_domain_services_directly FAILED"); } + @Test + @DisplayName("should fail when a domain model uses a source-retained annotation outside its allow-list") + void shouldFailWhenDomainModelUsesSourceRetainedAnnotationOutsideAllowList() throws IOException { + Path projectDir = createProjectWithSourceRetainedDomainAnnotation("source-retained-domain-annotation"); + + BuildResult result = createRunner(projectDir) + .withArguments("testArchitecture", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()).contains("source_annotation_dependencies_must_obey_core_allow_lists"); + String mergedXml = readAllXml(projectDir.resolve("build/reports/architecture-validator/xml")); + assertThat(mergedXml) + .contains("domain_must_only_depend_on_domain_or_jdk_core") + .contains("lombok.Getter"); + } + private Path createProjectWithFailingArchitectureTest(String projectName, boolean ignoreFailures) throws IOException { Path projectDir = tempDir.resolve(projectName); Files.createDirectories(projectDir); @@ -180,6 +196,65 @@ void shouldFlagArchitectureViolation() { return projectDir; } + private Path createProjectWithSourceRetainedDomainAnnotation(String projectName) throws IOException { + Path projectDir = tempDir.resolve(projectName); + Files.createDirectories(projectDir); + + write(projectDir.resolve("settings.gradle"), """ + pluginManagement { + repositories { + gradlePluginPortal() + } + } + + rootProject.name = '%s' + """.formatted(projectName)); + + write(projectDir.resolve("build.gradle"), """ + plugins { + id 'java' + id 'com.arc-e-tect.architecture-validator' + } + + group = 'com.example.archtest' + version = '0.0.1' + + repositories { + mavenCentral() + } + + architectureValidator { + basePackage = 'com.example.archtest' + } + """); + + write(projectDir.resolve("src/main/java/lombok/Getter.java"), """ + package lombok; + + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.SOURCE) + @Target(ElementType.TYPE) + public @interface Getter { + } + """); + + write(projectDir.resolve("src/main/java/com/example/archtest/application/domain/model/Order.java"), """ + package com.example.archtest.application.domain.model; + + import lombok.Getter; + + @Getter + public record Order(String id) { + } + """); + + return projectDir; + } + private Path createProjectWithEmptyHexagonalPackageStructure(String projectName) throws IOException { Path projectDir = tempDir.resolve(projectName); Files.createDirectories(projectDir); diff --git a/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTaskTest.java b/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTaskTest.java index 0a9d12b..c1b51f9 100644 --- a/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTaskTest.java +++ b/architecture-validator/src/test/java/com/arc_e_tect/gradle/architecture/GenerateArchitectureTestsTaskTest.java @@ -439,6 +439,83 @@ void generateShouldFailWhenOutputDirectoryCannotBeReset() throws Exception { } } + @Test + @DisplayName("generateShouldReportSourceRetainedAnnotationsOutsideCoreAllowLists") + void generateShouldReportSourceRetainedAnnotationsOutsideCoreAllowLists() throws Exception { + GenerateArchitectureTestsTask task = newTask("generateArchitectureTestsSourceAnnotations"); + configureDefaults(task, tempDir.resolve("generated")); + + writeJavaSource("application/domain/model/Order.java", """ + package com.example.architecture.application.domain.model; + + import lombok.Getter; + + @Getter + class Order { + } + """); + writeJavaSource("application/domain/service/OrderService.java", """ + package com.example.architecture.application.domain.service; + + import lombok.Getter; + + @Getter + class OrderService { + } + """); + writeJavaSource("application/port/inbound/CreateOrderUseCase.java", """ + package com.example.architecture.application.port.inbound; + + import lombok.Getter; + + @Getter + interface CreateOrderUseCase { + } + """); + + task.generate(); + + String generatedTest = Files.readString(tempDir.resolve( + "generated/com/arc_e_tect/gradle/architecture/generated/SourceDependencyValidationTest.java")); + assertThat(generatedTest) + .contains("domain_must_only_depend_on_domain_or_jdk_core") + .contains("domain_services_must_only_depend_on_domain_core_and_ports") + .contains("ports_must_only_depend_on_domain_or_jdk_core") + .contains("lombok.Getter"); + } + + @Test + @DisplayName("generateShouldReportWildcardAndFullyQualifiedSourceAnnotationsOutsideCoreAllowLists") + void generateShouldReportWildcardAndFullyQualifiedSourceAnnotationsOutsideCoreAllowLists() throws Exception { + GenerateArchitectureTestsTask task = newTask("generateArchitectureTestsQualifiedSourceAnnotations"); + configureDefaults(task, tempDir.resolve("generated")); + + writeJavaSource("application/domain/model/Invoice.java", """ + package com.example.architecture.application.domain.model; + + import lombok.*; + + @Getter + class Invoice { + } + """); + writeJavaSource("application/domain/model/Customer.java", """ + package com.example.architecture.application.domain.model; + + @lombok.Value + class Customer { + } + """); + + task.generate(); + + String generatedTest = Files.readString(tempDir.resolve( + "generated/com/arc_e_tect/gradle/architecture/generated/SourceDependencyValidationTest.java")); + assertThat(generatedTest) + .contains("lombok.Getter") + .contains("lombok.Value"); + } + private GenerateArchitectureTestsTask newTask(String taskName) { return ProjectBuilder.builder() .withProjectDir(tempDir.toFile()) @@ -447,6 +524,12 @@ private GenerateArchitectureTestsTask newTask(String taskName) { .create(taskName, GenerateArchitectureTestsTask.class); } + private void writeJavaSource(String relativePath, String source) throws Exception { + Path file = tempDir.resolve("src/main/java/com/example/architecture").resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } + private void configureDefaults(GenerateArchitectureTestsTask task, Path outputRoot) { task.getBasePackage().set("com.example.architecture"); task.getInPorts().set(List.of("..application.port.inbound.."));