Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ by surefire and once here.
| `rules` | Explicit rule references, see below. | `[]` |
| `serviceLoaderEnabled` | Whether rule providers should be discovered from the test classpath. | `true` |
| `analyzeTestClasses` | Whether the test classes should be analyzed alongside the main classes. | `false` |
| `compileIfMissing` | Whether to compile the module when it has not been compiled already. | `false` |
| `severity` | The severity reported for a rule without an entry in `ruleSeverities`. | `MAJOR` |
| `ruleSeverities` | Severity per rule name, overriding `severity`. | `{}` |

Expand Down Expand Up @@ -241,6 +242,38 @@ META-INF/services/io.github.finoid.maven.plugins.codequality.archunit.ArchRulePr
Provider names are chosen by the library, so keep them usable as XML element names if consumers should be able to
override their severity.

#### Phase requirement

The step reads the classes the build has already produced, rather than forking a compiler of its own the way Error
Prone and the Checker Framework do. It therefore has to run at or after `compile`. Bound to an earlier phase, or
invoked on the command line ahead of one, it reports a missing prerequisite and skips:

```
[INFO] Step ARCH_UNIT is missing prerequisites to run. Cause: the module has no compiled classes,
the goal has to run at or after the compile phase. Skipping...
```

Running the goal directly therefore needs a phase in front of it:

```shell
mvn clean compile io.github.finoid:codequality-maven-plugin:code-quality@maven-code-quality
```

Or let the step compile the module itself:

```xml
<archUnit>
<enabled>true</enabled>
<compileIfMissing>true</compileIfMissing>
</archUnit>
```

The compilation goes into `target/archunit-classes`, so it neither overwrites the output of the build nor leads a
later phase to believe the module is already built, and that directory is cleaned before each run. It reproduces the
release level and the annotation processors of the module, which covers a Lombok using service, but not a bespoke
compiler configuration - additional compiler arguments, generated source roots, a module path. Where that matters,
run the goal after the compile phase instead and analyze exactly what the build produced.

#### Where the rules live

Both sources load from a jar just as happily as from the module's own classes, so a shared rule library can be wired
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@
import io.github.finoid.maven.plugins.codequality.report.Violation;
import io.github.finoid.maven.plugins.codequality.step.ViolationConverter;
import io.github.finoid.maven.plugins.codequality.util.Precondition;
import org.apache.maven.project.MavenProject;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -40,18 +38,19 @@ public ArchUnitAnalyzer(final ViolationConverter violationConverter) {
/**
* Evaluates the given rules.
*
* @param rules the rules to evaluate
* @param configuration the step configuration
* @param context the context of the current mojo execution
* @param classDirectories the directories holding the compiled classes to analyze
* @param rules the rules to evaluate
* @param configuration the step configuration
* @param context the context of the current mojo execution
* @return the violations found, empty when there is nothing to analyze
*/
public List<Violation> analyze(final List<NamedArchRule> rules, final ArchUnitConfiguration configuration,
final ExecutionContext context) {
final List<Path> classDirectories = classDirectoriesOf(context.getProject(), configuration.isAnalyzeTestClasses());

public List<Violation> analyze(final List<Path> classDirectories, final List<NamedArchRule> rules,
final ArchUnitConfiguration configuration, final ExecutionContext context) {
if (classDirectories.isEmpty()) {
// Normally unreachable, the step declares the output directory as a prerequisite. Warned about rather
// than debugged, so an empty analysis is never mistaken for a clean one.
context.getLog()
.debug("No compiled classes to analyze with ArchUnit. Skipping...");
.warn("No compiled classes to analyze with ArchUnit, every rule will report nothing. Skipping...");

return List.of();
}
Expand Down Expand Up @@ -128,23 +127,4 @@ private static String singleLine(final String message) {
.trim();
}

private static List<Path> classDirectoriesOf(final MavenProject project, final boolean includeTestClasses) {
final List<Path> directories = new ArrayList<>();

addIfDirectory(directories, project.getBuild().getOutputDirectory());

if (includeTestClasses) {
addIfDirectory(directories, project.getBuild().getTestOutputDirectory());
}

return directories;
}

private static void addIfDirectory(final List<Path> directories, final String directory) {
final Path path = Path.of(directory);

if (Files.isDirectory(path)) {
directories.add(path);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
* <p>
Expand All @@ -34,16 +54,25 @@
*/
@Singleton
public class ArchUnitStep implements Step<ArchUnitConfiguration> {
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
Expand All @@ -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;
}

Expand All @@ -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<Violation> executeStep(final ArchUnitConfiguration configuration, final ExecutionContext context) {
Expand All @@ -93,7 +138,7 @@ private List<Violation> 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);
}
Expand All @@ -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.
* <p>
* 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<Path> classDirectoriesOf(final ArchUnitConfiguration configuration, final ExecutionContext context) {
final MavenProject project = context.getProject();

final List<Path> 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}.
*
* <p>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<MojoExecutor.Element> 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.
* <p>
Expand Down
Loading
Loading