directories, final String directory) {
- final Path path = Path.of(directory);
-
- if (Files.isDirectory(path)) {
- directories.add(path);
- }
- }
}
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/configuration/ArchUnitConfiguration.java b/src/main/java/io/github/finoid/maven/plugins/codequality/configuration/ArchUnitConfiguration.java
index 9e88f96..61dba53 100644
--- a/src/main/java/io/github/finoid/maven/plugins/codequality/configuration/ArchUnitConfiguration.java
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/configuration/ArchUnitConfiguration.java
@@ -48,6 +48,21 @@ public class ArchUnitConfiguration implements Configuration {
@Parameter(property = "cq.archunit.serviceLoaderEnabled")
private boolean serviceLoaderEnabled = true;
+ /**
+ * Whether the module should be compiled when it has not been already.
+ *
+ * Off by default. In the ordinary binding the goal runs at {@code verify}, where the classes are long since
+ * built, and a code quality goal quietly compiling the module is a side effect nobody asked for - a compilation
+ * failure would surface as a code quality failure. Switch it on to invoke the goal directly, without putting a
+ * phase in front of it.
+ *
+ * The compilation is a separate one, into {@code target/archunit-classes}, so it neither overwrites nor satisfies
+ * the output of the build itself. It reproduces the release level and the annotation processors of the module,
+ * but not a bespoke compiler configuration, so what is analyzed may differ from what the build produces.
+ */
+ @Parameter(property = "cq.archunit.compileIfMissing")
+ private boolean compileIfMissing = false;
+
/**
* Whether the test classes of the module should be analyzed alongside its main classes.
*
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/step/ArchUnitStep.java b/src/main/java/io/github/finoid/maven/plugins/codequality/step/ArchUnitStep.java
index 4f295c6..e36a77b 100644
--- a/src/main/java/io/github/finoid/maven/plugins/codequality/step/ArchUnitStep.java
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/step/ArchUnitStep.java
@@ -1,6 +1,7 @@
package io.github.finoid.maven.plugins.codequality.step;
import io.github.finoid.maven.plugins.codequality.ExecutionContext;
+import io.github.finoid.maven.plugins.codequality.MavenAnnotationProcessorsManager;
import io.github.finoid.maven.plugins.codequality.archunit.ArchRuleResolver;
import io.github.finoid.maven.plugins.codequality.archunit.ArchUnitAnalyzer;
import io.github.finoid.maven.plugins.codequality.archunit.NamedArchRule;
@@ -9,18 +10,37 @@
import io.github.finoid.maven.plugins.codequality.configuration.CodeQualityConfiguration;
import io.github.finoid.maven.plugins.codequality.exceptions.CodeQualityException;
import io.github.finoid.maven.plugins.codequality.report.Violation;
+import io.github.finoid.maven.plugins.codequality.util.CollectorUtils;
+import io.github.finoid.maven.plugins.codequality.util.MojoUtils.ElementUtils;
+import io.github.finoid.maven.plugins.codequality.util.MojoUtils.PluginUtils;
import io.github.finoid.maven.plugins.codequality.util.Precondition;
+import io.github.finoid.maven.plugins.codequality.util.PropertyUtils;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.BuildPluginManager;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
import org.apache.maven.project.MavenProject;
+import org.twdata.maven.mojoexecutor.MojoExecutor;
import javax.inject.Inject;
import javax.inject.Singleton;
+import java.io.File;
import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.net.URL;
import java.net.URLClassLoader;
+import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
+import static org.twdata.maven.mojoexecutor.MojoExecutor.configuration;
+import static org.twdata.maven.mojoexecutor.MojoExecutor.element;
+import static org.twdata.maven.mojoexecutor.MojoExecutor.executeMojo;
+import static org.twdata.maven.mojoexecutor.MojoExecutor.executionEnvironment;
+import static org.twdata.maven.mojoexecutor.MojoExecutor.goal;
+
/**
* Step which evaluates ArchUnit rules against the compiled classes of the module.
*
@@ -34,16 +54,25 @@
*/
@Singleton
public class ArchUnitStep implements Step {
+ private static final String ARCH_UNIT_CLASSES = "archunit-classes";
+
private final ArchRuleResolver archRuleResolver;
private final ArchUnitAnalyzer archUnitAnalyzer;
private final TestClassPathResolver testClassPathResolver;
+ private final CodeQualityConfiguration codeQualityConfiguration;
+ private final MavenSession mavenSession;
+ private final BuildPluginManager pluginManager;
@Inject
public ArchUnitStep(final ArchRuleResolver archRuleResolver, final ArchUnitAnalyzer archUnitAnalyzer,
- final TestClassPathResolver testClassPathResolver) {
+ final TestClassPathResolver testClassPathResolver, final CodeQualityConfiguration codeQualityConfiguration,
+ final MavenSession mavenSession, final BuildPluginManager pluginManager) {
this.archRuleResolver = Precondition.nonNull(archRuleResolver, "ArchRuleResolver shouldn't be null");
this.archUnitAnalyzer = Precondition.nonNull(archUnitAnalyzer, "ArchUnitAnalyzer shouldn't be null");
this.testClassPathResolver = Precondition.nonNull(testClassPathResolver, "TestClassPathResolver shouldn't be null");
+ this.codeQualityConfiguration = Precondition.nonNull(codeQualityConfiguration, "CodeQualityConfiguration shouldn't be null");
+ this.mavenSession = Precondition.nonNull(mavenSession, "MavenSession shouldn't be null");
+ this.pluginManager = Precondition.nonNull(pluginManager, "BuildPluginManager shouldn't be null");
}
@Override
@@ -57,6 +86,18 @@ public PrerequisiteResult hasPrerequisites(final ArchUnitConfiguration configura
return PrerequisiteResult.notOK("no rules are configured and the service loader is disabled");
}
+ /*
+ * Unlike the analyzers which fork a compiler of their own, this step reads the classes the build has already
+ * produced. Bound to a phase before compile - or invoked directly on the command line ahead of one - there is
+ * nothing to read, and every rule would pass for the wrong reason. Reported as a missing prerequisite rather
+ * than as an empty result, so a run which cannot find anything never looks like a clean one.
+ */
+ if (!configuration.isCompileIfMissing() && !Files.isDirectory(outputDirectoryOf(context.getProject()))) {
+ return PrerequisiteResult.notOK(
+ "the module has no compiled classes, the goal has to run at or after the compile phase."
+ + " Set archUnit.compileIfMissing to compile it instead");
+ }
+
return PrerequisiteResult.OK;
}
@@ -73,8 +114,12 @@ public StepResult execute(final CodeQualityConfiguration codeQualityConfiguratio
@Override
public CleanContext getCleanContext() {
- // Nothing is written between runs: the classes are re-imported and the rules re-evaluated on every execution.
- return CleanContext.DO_NOTHING;
+ /*
+ * Only the output of an own compilation is cleaned, and only that. The classes of the build itself are left
+ * alone, but a stale class of ours - from a source file since deleted or renamed - would otherwise be
+ * analyzed forever, which is the sort of finding nobody can explain.
+ */
+ return new CleanContext(CleanContext.CleanType.DIRECTORY, ARCH_UNIT_CLASSES, "**/*");
}
private List executeStep(final ArchUnitConfiguration configuration, final ExecutionContext context) {
@@ -93,7 +138,7 @@ private List executeStep(final ArchUnitConfiguration configuration, f
warnOnUnmatchedSeverities(configuration, rules, context);
- return archUnitAnalyzer.analyze(rules, configuration, context);
+ return archUnitAnalyzer.analyze(classDirectoriesOf(configuration, context), rules, configuration, context);
} catch (final IOException e) {
throw new CodeQualityException(String.format("Failed to close the ArchUnit class loader. Cause: %s", e.getMessage()), e);
}
@@ -119,6 +164,95 @@ private void warnOnUnmatchedSeverities(final ArchUnitConfiguration configuration
.warn(String.format("ArchUnit severity override [%s] matches no resolved rule. Known rules: %s", name, resolvedNames)));
}
+ /**
+ * The directories holding the classes to analyze.
+ *
+ * Ordinarily the output of the build. When the module has not been compiled and {@code compileIfMissing} is set,
+ * a compilation of its own is run first, into a directory of its own so that neither the output of the build is
+ * overwritten nor a later phase led to believe the module is already built.
+ */
+ private List classDirectoriesOf(final ArchUnitConfiguration configuration, final ExecutionContext context) {
+ final MavenProject project = context.getProject();
+
+ final List directories = new ArrayList<>();
+
+ if (Files.isDirectory(outputDirectoryOf(project))) {
+ directories.add(outputDirectoryOf(project));
+ } else if (configuration.isCompileIfMissing()) {
+ directories.add(compile(context));
+ }
+
+ if (configuration.isAnalyzeTestClasses() && Files.isDirectory(testOutputDirectoryOf(project))) {
+ directories.add(testOutputDirectoryOf(project));
+ }
+
+ return directories;
+ }
+
+ /**
+ * Compiles the main sources of the module into {@code target/archunit-classes}.
+ *
+ * The release level and the annotation processors of the module are reproduced, which covers the common case
+ * of a Lombok using service. A module with a bespoke compiler configuration - additional compiler arguments,
+ * generated source roots, a module path - is not fully reproduced, so what is analyzed can differ from what the
+ * build itself produces. Running the goal after the compile phase avoids the question entirely.
+ */
+ private Path compile(final ExecutionContext context) {
+ final MavenProject project = context.getProject();
+
+ final PluginDescriptor descriptor = PluginUtils.pluginDescriptor("org.apache.maven.plugins", "maven-compiler-plugin",
+ codeQualityConfiguration.getVersions().getMavenCompiler());
+
+ final String javaVersion = PropertyUtils.valueOrFallback(project.getProperties(), "java.version", "21");
+ final Path outputDirectory = Path.of(project.getBuild().getDirectory(), ARCH_UNIT_CLASSES);
+
+ // The forked compile assigns an artifact file to the project, which would later be reported as
+ // 'The packaging for this project did not assign a file to the build artifact.'
+ final File originalArtifactFile = project.getArtifact()
+ .getFile();
+
+ context.getLog()
+ .info(String.format("Compiling %s for ArchUnit, no compiled classes were found", project.getArtifactId()));
+
+ try {
+ executeMojo(
+ PluginUtils.pluginOfDescriptor(descriptor),
+ goal("compile"),
+ configuration(
+ element(MojoExecutor.name("source"), javaVersion),
+ element(MojoExecutor.name("target"), javaVersion),
+ element(MojoExecutor.name("release"), javaVersion),
+ element(MojoExecutor.name("outputDirectory"), outputDirectory.toString()),
+ element(MojoExecutor.name("annotationProcessorPaths"), annotationProcessorPathsOf(project)
+ .toArray(MojoExecutor.Element[]::new))
+ ),
+ executionEnvironment(project, mavenSession, pluginManager));
+
+ return outputDirectory;
+ } catch (final MojoExecutionException e) {
+ throw new CodeQualityException(
+ String.format("Failed to compile module [%s] for ArchUnit. Cause: %s", project.getArtifactId(), e.getMessage()), e);
+ } finally {
+ project.getArtifact()
+ .setFile(originalArtifactFile);
+ }
+ }
+
+ private List annotationProcessorPathsOf(final MavenProject project) {
+ return new MavenAnnotationProcessorsManager(project, codeQualityConfiguration).annotationPaths()
+ .stream()
+ .map(path -> ElementUtils.annotationProcessor(path.getGroupId(), path.getArtifactId(), path.getVersion()))
+ .collect(CollectorUtils.toMutableList());
+ }
+
+ private static Path outputDirectoryOf(final MavenProject project) {
+ return Path.of(project.getBuild().getOutputDirectory());
+ }
+
+ private static Path testOutputDirectoryOf(final MavenProject project) {
+ return Path.of(project.getBuild().getTestOutputDirectory());
+ }
+
/**
* A class loader over the test classpath of the module, delegating to the class loader of this plugin.
*
diff --git a/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzerUnitTest.java b/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzerUnitTest.java
index 3a89e83..281f973 100644
--- a/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzerUnitTest.java
+++ b/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzerUnitTest.java
@@ -68,7 +68,7 @@ void reportsViolationsWithTheirSourceLocation() {
.haveSimpleName("SomethingElse")
.allowEmptyShould(true);
- final List violations = unit.analyze(List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
+ final List violations = unit.analyze(classDirectories(), List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
Assertions.assertEquals(1, violations.size());
@@ -92,7 +92,7 @@ void reportsNothingForASatisfiedRule() {
.haveSimpleName("ArchUnitAnalyzer")
.allowEmptyShould(true);
- Assertions.assertTrue(unit.analyze(List.of(NamedArchRule.of("NAMING", rule)), configuration(), context).isEmpty());
+ Assertions.assertTrue(unit.analyze(classDirectories(), List.of(NamedArchRule.of("NAMING", rule)), configuration(), context).isEmpty());
}
@Test
@@ -109,7 +109,7 @@ void appliesThePerRuleSeverity() {
.haveSimpleName("SomethingElse")
.allowEmptyShould(true);
- final List violations = unit.analyze(List.of(NamedArchRule.of("NAMING", rule)), configuration, context);
+ final List violations = unit.analyze(classDirectories(), List.of(NamedArchRule.of("NAMING", rule)), configuration, context);
Assertions.assertEquals(Severity.BLOCKER, violations.getFirst().getSeverity());
}
@@ -124,8 +124,8 @@ void producesAStableFingerprint() {
.haveSimpleName("SomethingElse")
.allowEmptyShould(true);
- final List first = unit.analyze(List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
- final List second = unit.analyze(List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
+ final List first = unit.analyze(classDirectories(), List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
+ final List second = unit.analyze(classDirectories(), List.of(NamedArchRule.of("NAMING", rule)), configuration(), context);
Assertions.assertEquals(first.getFirst().getFingerprint(), second.getFirst().getFingerprint());
}
@@ -142,7 +142,11 @@ void skipsAModuleWithoutCompiledClasses() {
final ExecutionContext emptyContext = ExecutionContext.of(emptyProject, log);
- Assertions.assertTrue(unit.analyze(List.of(), configuration(), emptyContext).isEmpty());
+ Assertions.assertTrue(unit.analyze(List.of(), List.of(), configuration(), emptyContext).isEmpty());
+ }
+
+ private static List classDirectories() {
+ return List.of(WORKING_DIRECTORY.resolve("target/classes"));
}
private static ArchUnitConfiguration configuration() {
diff --git a/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitStepUnitTest.java b/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitStepUnitTest.java
new file mode 100644
index 0000000..b7a5f10
--- /dev/null
+++ b/src/test/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitStepUnitTest.java
@@ -0,0 +1,114 @@
+package io.github.finoid.maven.plugins.codequality.archunit;
+
+import io.github.finoid.maven.plugins.codequality.ExecutionContext;
+import io.github.finoid.maven.plugins.codequality.configuration.ArchUnitConfiguration;
+import io.github.finoid.maven.plugins.codequality.fixtures.UnitTest;
+import io.github.finoid.maven.plugins.codequality.step.ArchUnitStep;
+import io.github.finoid.maven.plugins.codequality.step.Step;
+import io.github.finoid.maven.plugins.codequality.configuration.CodeQualityConfiguration;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Build;
+import org.apache.maven.plugin.BuildPluginManager;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+
+class ArchUnitStepUnitTest extends UnitTest {
+ @Mock
+ private ArchRuleResolver archRuleResolver;
+ @Mock
+ private ArchUnitAnalyzer archUnitAnalyzer;
+ @Mock
+ private TestClassPathResolver testClassPathResolver;
+ @Mock
+ private MavenSession mavenSession;
+ @Mock
+ private BuildPluginManager pluginManager;
+ @Mock
+ private Log log;
+
+ @TempDir
+ private Path temporaryDirectory;
+
+ private ArchUnitStep unit;
+
+ @BeforeEach
+ void beforeEach() {
+ unit = new ArchUnitStep(archRuleResolver, archUnitAnalyzer, testClassPathResolver,
+ new CodeQualityConfiguration(), mavenSession, pluginManager);
+ }
+
+ @Test
+ @DisplayName("Given a module which has not been compiled, Then the step reports a missing prerequisite")
+ void reportsMissingPrerequisiteWithoutCompiledClasses() {
+ final ArchUnitConfiguration configuration = new ArchUnitConfiguration();
+
+ final Step.PrerequisiteResult result =
+ unit.hasPrerequisites(configuration, context(temporaryDirectory.resolve("never-compiled")));
+
+ Assertions.assertFalse(result.hasAllPrerequisites());
+ Assertions.assertNotNull(result.cause());
+ Assertions.assertTrue(result.cause().contains("compile"), result.cause());
+ }
+
+ @Test
+ @DisplayName("Given compiled classes, Then the prerequisites are met")
+ void acceptsACompiledModule() throws IOException {
+ final Path classes = Files.createDirectory(temporaryDirectory.resolve("classes"));
+
+ Assertions.assertTrue(unit.hasPrerequisites(new ArchUnitConfiguration(), context(classes)).hasAllPrerequisites());
+ }
+
+ @Test
+ @DisplayName("Given an uncompiled module and compileIfMissing, Then the prerequisites are met")
+ void acceptsAnUncompiledModuleWhenAllowedToCompileIt() {
+ final ArchUnitConfiguration configuration = new ArchUnitConfiguration();
+ configuration.setCompileIfMissing(true);
+
+ Assertions.assertTrue(
+ unit.hasPrerequisites(configuration, context(temporaryDirectory.resolve("never-compiled"))).hasAllPrerequisites());
+ }
+
+ @Test
+ @DisplayName("Given an uncompiled module, Then the cause names the option which would compile it")
+ void namesTheCompileOptionInTheCause() {
+ final Step.PrerequisiteResult result =
+ unit.hasPrerequisites(new ArchUnitConfiguration(), context(temporaryDirectory.resolve("never-compiled")));
+
+ Assertions.assertTrue(result.cause().contains("compileIfMissing"), result.cause());
+ }
+
+ @Test
+ @DisplayName("Given no rules and no service loader, Then the step reports a missing prerequisite")
+ void reportsMissingPrerequisiteWithoutRules() throws IOException {
+ final Path classes = Files.createDirectory(temporaryDirectory.resolve("classes"));
+
+ final ArchUnitConfiguration configuration = new ArchUnitConfiguration();
+ configuration.setServiceLoaderEnabled(false);
+ configuration.setRules(Set.of());
+
+ final Step.PrerequisiteResult result = unit.hasPrerequisites(configuration, context(classes));
+
+ Assertions.assertFalse(result.hasAllPrerequisites());
+ }
+
+ private ExecutionContext context(final Path outputDirectory) {
+ final MavenProject project = new MavenProject();
+
+ final Build build = new Build();
+ build.setOutputDirectory(outputDirectory.toString());
+ project.setBuild(build);
+
+ return ExecutionContext.of(project, log);
+ }
+}