diff --git a/README.md b/README.md
index 9ee1ebc..581c032 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,13 @@ For continuous use across builds, include the plugin in your project’s pom.xml
true
+
+
+ true
+
+ com.example.arch.MyRules#NO_CYCLES
+
+
@@ -166,3 +173,119 @@ For continuous use across builds, include the plugin in your project’s pom.xml
| `checkers` | The list of checkers to be run. | See `CheckerFrameworkConfiguration` class in your codebase. |
| `compilerArgs` | Custom compiler arguments. | `[]` |
| `versions.checkerFramework` | The Checker Framework version to use. | `3.48.1` |
+
+### ArchUnit configuration
+
+Evaluates [ArchUnit](https://www.archunit.org/) rules against the compiled classes of the module and reports the
+findings alongside the other analyzers, with the source file and line the violation belongs to.
+
+Unlike the other analyzers the checks are not built in: the rules come from the project. Running them here rather than
+as `@ArchTest` JUnit tests means they also run when the build skips tests, and that their findings reach the GitLab
+code quality report. A project which keeps its ArchUnit tests should be aware the rules are then evaluated twice, once
+by surefire and once here.
+
+| Parameter | Description | Default |
+|------------------------|-------------------------------------------------------------------------|---------|
+| `enabled` | Whether the ArchUnit analyzer should be enabled. | `false` |
+| `permissive` | Whether the execution should be permissive (not fail on violations). | `true` |
+| `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` |
+| `severity` | The severity reported for a rule without an entry in `ruleSeverities`. | `MAJOR` |
+| `ruleSeverities` | Severity per rule name, overriding `severity`. | `{}` |
+
+#### Referencing rules explicitly
+
+Three forms are accepted. The referenced classes are loaded from the **test** classpath, so the rule library only has
+to be a test scoped dependency:
+
+```xml
+
+ true
+
+
+ com.example.arch.MyRules#NO_CYCLES
+
+ com.example.arch.MyRules#noCycles()
+
+ com.example.arch.MyRules
+
+
+ BLOCKER
+
+
+```
+
+A rule is reported under `SimpleClassName.member`, which is also the key `ruleSeverities` is looked up by. Neither `#`
+nor the parentheses of a method reference are legal in an XML element name, hence the normalisation. An override
+matching no resolved rule is warned about rather than silently ignored.
+
+#### Providing rules from a library
+
+A rule library can register itself instead, so consuming projects need no configuration beyond enabling the step.
+Implement `ArchRuleProvider` and ship a service entry:
+
+```java
+public class MyRuleProvider implements ArchRuleProvider {
+ @Override
+ public Collection rules() {
+ return List.of(NamedArchRule.of("NO_CYCLES", MyRules.NO_CYCLES));
+ }
+}
+```
+
+```
+META-INF/services/io.github.finoid.maven.plugins.codequality.archunit.ArchRuleProvider
+```
+
+Provider names are chosen by the library, so keep them usable as XML element names if consumers should be able to
+override their severity.
+
+#### 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
+in two ways.
+
+As a test scoped dependency of the analyzed module:
+
+```xml
+
+ com.example
+ arch-rules
+ 1.0.0
+ test
+
+```
+
+Or as a dependency of the plugin declaration, which keeps it out of the project's own dependency tree entirely and
+lets a parent POM hand the rules to every module that inherits it:
+
+```xml
+
+ io.github.finoid
+ codequality-maven-plugin
+
+
+ com.example
+ arch-rules
+ 1.0.0
+
+
+
+```
+
+Explicit references and service loader discovery work through either.
+
+#### Dependency resolution scope
+
+The goal keeps resolving dependencies in **compile** scope. Widening it to test scope would resolve the test
+dependencies of every module whether or not this step is enabled, and would fail the goal on a test dependency which
+cannot be resolved. The step resolves the test classpath itself, through `ProjectDependenciesResolver`, and only when
+it actually runs.
+
+#### Class loading
+
+Rules are loaded through a class loader over the test classpath of the module, delegating to the plugin's own class
+loader. ArchUnit therefore always resolves to the copy the plugin was built against. Rules compiled against another
+1.x release link fine against it, since the types they touch (`ArchRule`, `ArchCondition`, `DescribedPredicate`) are
+stable across the line, but a project on a future 2.x release would need the plugin upgraded in step.
diff --git a/pom.xml b/pom.xml
index df2fb5d..76fd942 100644
--- a/pom.xml
+++ b/pom.xml
@@ -28,6 +28,7 @@
21UTF-8
+ 1.4.10.3.214.1.04.2.3
@@ -197,6 +198,11 @@
+
+ com.tngtech.archunit
+ archunit
+ ${archunit.version}
+ de.vandermeerasciitable
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/CodeQuality.java b/src/main/java/io/github/finoid/maven/plugins/codequality/CodeQuality.java
index 17b72f5..143fd9d 100644
--- a/src/main/java/io/github/finoid/maven/plugins/codequality/CodeQuality.java
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/CodeQuality.java
@@ -10,6 +10,7 @@
import io.github.finoid.maven.plugins.codequality.handlers.CleanHandler;
import io.github.finoid.maven.plugins.codequality.report.Severity;
import io.github.finoid.maven.plugins.codequality.report.ViolationReporter;
+import io.github.finoid.maven.plugins.codequality.step.ArchUnitStep;
import io.github.finoid.maven.plugins.codequality.step.CheckerFrameworkStep;
import io.github.finoid.maven.plugins.codequality.step.CheckstyleStep;
import io.github.finoid.maven.plugins.codequality.step.ErrorProneStep;
@@ -39,6 +40,7 @@ public class CodeQuality extends AbstractMojo {
private final CheckstyleStep checkstyleStep;
private final ErrorProneStep errorProneStep;
private final CheckerFrameworkStep checkerFrameworkStep;
+ private final ArchUnitStep archUnitStep;
private final CleanHandler cleanHandler;
private final StepResultsRepository stepResultsRepository;
private final ReactorCompletionTracker reactorCompletionTracker;
@@ -69,6 +71,7 @@ public CodeQuality(
final CheckstyleStep checkstyleStep,
final ErrorProneStep errorProneStep,
final CheckerFrameworkStep checkerFrameworkStep,
+ final ArchUnitStep archUnitStep,
final CleanHandler cleanHandler,
final MavenSession mavenSession,
final MavenProject project,
@@ -81,6 +84,7 @@ public CodeQuality(
this.checkstyleStep = Precondition.nonNull(checkstyleStep, "CheckstyleStep shouldn't be null");
this.errorProneStep = Precondition.nonNull(errorProneStep, "ErrorProneStep shouldn't be null");
this.checkerFrameworkStep = Precondition.nonNull(checkerFrameworkStep, "CheckerFrameworkStep shouldn't be null");
+ this.archUnitStep = Precondition.nonNull(archUnitStep, "ArchUnitStep shouldn't be null");
this.cleanHandler = Precondition.nonNull(cleanHandler, "CleanHandler shouldn't be null");
this.stepResultsRepository = Precondition.nonNull(stepResultsRepository, "StepResultsRepository shouldn't be null");
this.reactorCompletionTracker = Precondition.nonNull(reactorCompletionTracker, "ReactorCompletionTracker shouldn't be null");
@@ -126,7 +130,8 @@ private ProjectStepResults executeSteps(final ExecutionContext context) {
context.getProject().getName(),
executeStep(checkstyleStep, codeQualityConfiguration, codeQualityConfiguration.getCheckstyle(), context),
executeStep(errorProneStep, codeQualityConfiguration, codeQualityConfiguration.getErrorProne(), context),
- executeStep(checkerFrameworkStep, codeQualityConfiguration, codeQualityConfiguration.getCheckerFramework(), context)
+ executeStep(checkerFrameworkStep, codeQualityConfiguration, codeQualityConfiguration.getCheckerFramework(), context),
+ executeStep(archUnitStep, codeQualityConfiguration, codeQualityConfiguration.getArchUnit(), context)
);
stepResultsRepository.store(context.getProject(), projectStepResults);
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleProvider.java b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleProvider.java
new file mode 100644
index 0000000..85bbaf9
--- /dev/null
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleProvider.java
@@ -0,0 +1,23 @@
+package io.github.finoid.maven.plugins.codequality.archunit;
+
+import java.util.Collection;
+
+/**
+ * Supplies the ArchUnit rules a library wants applied to the projects consuming it.
+ *
+ * Implementations are discovered through {@link java.util.ServiceLoader} from the test classpath of the analyzed
+ * module, so a rule library declares itself by shipping a
+ * {@code META-INF/services/io.github.finoid.maven.plugins.codequality.archunit.ArchRuleProvider} entry. Implementations
+ * need a public no-args constructor.
+ *
+ * A library which does not want a dependency on this plugin does not have to implement anything: rules can be
+ * referenced directly from the plugin configuration instead, see {@code archUnit.rules}.
+ */
+public interface ArchRuleProvider {
+ /**
+ * The rules to apply.
+ *
+ * @return the rules, never null
+ */
+ Collection rules();
+}
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleResolver.java b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleResolver.java
new file mode 100644
index 0000000..1aa48ae
--- /dev/null
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchRuleResolver.java
@@ -0,0 +1,191 @@
+package io.github.finoid.maven.plugins.codequality.archunit;
+
+import com.tngtech.archunit.lang.ArchRule;
+import io.github.finoid.maven.plugins.codequality.ExecutionContext;
+import io.github.finoid.maven.plugins.codequality.configuration.ArchUnitConfiguration;
+import io.github.finoid.maven.plugins.codequality.exceptions.CodeQualityException;
+
+import javax.inject.Singleton;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+
+/**
+ * Resolves the rules to evaluate, from the plugin configuration and from the service loader.
+ *
+ * Both sources are read through a class loader over the test classpath of the analyzed module, so a rule library only
+ * has to be a test scoped dependency of the project. That class loader delegates to the class loader of this plugin,
+ * which means ArchUnit itself is always the copy this plugin was built against, even when the project depends on a
+ * different version. Rules compiled against another 1.x release link fine against it, since the types they touch
+ * ({@code ArchRule}, {@code ArchCondition}, {@code DescribedPredicate}) are stable across the line, but a project on a
+ * 2.x release would need this plugin to be upgraded in step.
+ */
+@Singleton
+public class ArchRuleResolver {
+ private static final String MEMBER_SEPARATOR = "#";
+ private static final String METHOD_SUFFIX = "()";
+
+ /**
+ * Resolves every configured and discovered rule.
+ *
+ * Duplicates by name are collapsed, so a rule both provided through the service loader and referenced explicitly
+ * is evaluated once.
+ *
+ * @param configuration the step configuration
+ * @param classLoader the class loader over the test classpath of the analyzed module
+ * @param context the context of the current mojo execution
+ * @return the rules to evaluate, in a stable order
+ */
+ public List resolve(final ArchUnitConfiguration configuration, final ClassLoader classLoader,
+ final ExecutionContext context) {
+ final Map byName = new LinkedHashMap<>();
+
+ if (configuration.isServiceLoaderEnabled()) {
+ fromServiceLoader(classLoader, context).forEach(rule -> byName.putIfAbsent(rule.name(), rule));
+ }
+
+ for (final String reference : configuration.getRules()) {
+ fromReference(reference, classLoader).forEach(rule -> byName.putIfAbsent(rule.name(), rule));
+ }
+
+ return new ArrayList<>(byName.values());
+ }
+
+ private List fromServiceLoader(final ClassLoader classLoader, final ExecutionContext context) {
+ final List rules = new ArrayList<>();
+
+ try {
+ for (final ArchRuleProvider provider : ServiceLoader.load(ArchRuleProvider.class, classLoader)) {
+ rules.addAll(provider.rules());
+ }
+ } catch (final ServiceConfigurationError e) {
+ // A broken provider on the classpath must not take the build down; the explicitly configured rules are
+ // still worth evaluating.
+ context.getLog()
+ .warn(String.format("Failed to load an ArchRuleProvider. Cause: %s", e.getMessage()));
+ }
+
+ return rules;
+ }
+
+ private List fromReference(final String reference, final ClassLoader classLoader) {
+ final int separator = reference.indexOf(MEMBER_SEPARATOR);
+
+ if (separator < 0) {
+ return fromClass(reference, classLoader);
+ }
+
+ final String className = reference.substring(0, separator);
+ final String memberName = reference.substring(separator + 1);
+ final Class> owner = loadClass(className, classLoader, reference);
+
+ if (memberName.endsWith(METHOD_SUFFIX)) {
+ final String methodName = memberName.substring(0, memberName.length() - METHOD_SUFFIX.length());
+
+ return List.of(fromMethod(owner, methodName, className, reference));
+ }
+
+ return List.of(fromField(owner, memberName, className, reference));
+ }
+
+ private List fromClass(final String className, final ClassLoader classLoader) {
+ final Class> type = loadClass(className, classLoader, className);
+
+ if (ArchRuleProvider.class.isAssignableFrom(type)) {
+ return new ArrayList<>(instantiateProvider(type, className).rules());
+ }
+
+ final List rules = new ArrayList<>();
+
+ for (final Field field : type.getDeclaredFields()) {
+ if (isStaticArchRule(field)) {
+ rules.add(fromField(type, field.getName(), className, className + MEMBER_SEPARATOR + field.getName()));
+ }
+ }
+
+ if (rules.isEmpty()) {
+ throw new CodeQualityException(String.format(
+ "ArchUnit rule reference [%s] resolved to a class with neither an ArchRuleProvider implementation nor a"
+ + " static ArchRule field", className));
+ }
+
+ return rules;
+ }
+
+ private NamedArchRule fromField(final Class> owner, final String fieldName, final String className, final String reference) {
+ try {
+ final Field field = owner.getDeclaredField(fieldName);
+
+ if (!isStaticArchRule(field)) {
+ throw new CodeQualityException(String.format(
+ "ArchUnit rule reference [%s] is not a static field of type ArchRule", reference));
+ }
+
+ field.setAccessible(true);
+
+ return NamedArchRule.of(nameOf(className, fieldName), (ArchRule) field.get(null));
+ } catch (final NoSuchFieldException | IllegalAccessException e) {
+ throw new CodeQualityException(String.format("Failed to read ArchUnit rule [%s]. Cause: %s", reference, e.getMessage()), e);
+ }
+ }
+
+ private NamedArchRule fromMethod(final Class> owner, final String methodName, final String className, final String reference) {
+ try {
+ final Method method = owner.getDeclaredMethod(methodName);
+
+ if (!Modifier.isStatic(method.getModifiers()) || !ArchRule.class.isAssignableFrom(method.getReturnType())) {
+ throw new CodeQualityException(String.format(
+ "ArchUnit rule reference [%s] is not a static no-args method returning an ArchRule", reference));
+ }
+
+ method.setAccessible(true);
+
+ return NamedArchRule.of(nameOf(className, methodName), (ArchRule) method.invoke(null));
+ } catch (final ReflectiveOperationException e) {
+ throw new CodeQualityException(String.format("Failed to invoke ArchUnit rule [%s]. Cause: %s", reference, e.getMessage()), e);
+ }
+ }
+
+ private ArchRuleProvider instantiateProvider(final Class> type, final String reference) {
+ try {
+ return (ArchRuleProvider) type.getDeclaredConstructor()
+ .newInstance();
+ } catch (final ReflectiveOperationException e) {
+ throw new CodeQualityException(
+ String.format("Failed to instantiate ArchRuleProvider [%s]. Cause: %s", reference, e.getMessage()), e);
+ }
+ }
+
+ private Class> loadClass(final String className, final ClassLoader classLoader, final String reference) {
+ try {
+ return Class.forName(className, true, classLoader);
+ } catch (final ClassNotFoundException e) {
+ throw new CodeQualityException(String.format(
+ "ArchUnit rule reference [%s] could not be loaded from the test classpath of the module", reference), e);
+ }
+ }
+
+ private static boolean isStaticArchRule(final Field field) {
+ return Modifier.isStatic(field.getModifiers()) && ArchRule.class.isAssignableFrom(field.getType());
+ }
+
+ /**
+ * The reported name of a rule, being the referenced member qualified by the simple name of its class.
+ *
+ * Neither {@code #} nor the parentheses of a method reference survive as an XML element name, and the severity
+ * overrides are keyed by rule name in the plugin configuration, so the reference is normalised to
+ * {@code SimpleClassName.member} - which is both a legal element name and shorter to read in a report.
+ */
+ private static String nameOf(final String className, final String memberName) {
+ final int lastDot = className.lastIndexOf('.');
+ final String simpleName = lastDot < 0 ? className : className.substring(lastDot + 1);
+
+ return simpleName + '.' + memberName;
+ }
+}
diff --git a/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzer.java b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzer.java
new file mode 100644
index 0000000..69e0544
--- /dev/null
+++ b/src/main/java/io/github/finoid/maven/plugins/codequality/archunit/ArchUnitAnalyzer.java
@@ -0,0 +1,150 @@
+package io.github.finoid.maven.plugins.codequality.archunit;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.domain.SourceCodeLocation;
+import com.tngtech.archunit.core.domain.properties.HasSourceCodeLocation;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.lang.EvaluationResult;
+import com.tngtech.archunit.lang.ViolationHandler;
+import io.github.finoid.maven.plugins.codequality.ExecutionContext;
+import io.github.finoid.maven.plugins.codequality.configuration.ArchUnitConfiguration;
+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;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Evaluates ArchUnit rules against the compiled classes of a module and turns the results into violations.
+ *
+ * The classes are imported once and shared by every rule, since the import is by far the expensive part.
+ */
+@Singleton
+public class ArchUnitAnalyzer {
+ private final ViolationConverter violationConverter;
+
+ @Inject
+ public ArchUnitAnalyzer(final ViolationConverter violationConverter) {
+ this.violationConverter = Precondition.nonNull(violationConverter, "ViolationConverter shouldn't be null");
+ }
+
+ /**
+ * Evaluates the given rules.
+ *
+ * @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 analyze(final List rules, final ArchUnitConfiguration configuration,
+ final ExecutionContext context) {
+ final List classDirectories = classDirectoriesOf(context.getProject(), configuration.isAnalyzeTestClasses());
+
+ if (classDirectories.isEmpty()) {
+ context.getLog()
+ .debug("No compiled classes to analyze with ArchUnit. Skipping...");
+
+ return List.of();
+ }
+
+ final JavaClasses javaClasses = new ClassFileImporter().importPaths(classDirectories);
+ final SourceFileResolver sourceFileResolver =
+ new SourceFileResolver(context.getProject(), configuration.isAnalyzeTestClasses());
+
+ final List violations = new ArrayList<>();
+
+ for (final NamedArchRule rule : rules) {
+ violations.addAll(evaluate(rule, javaClasses, configuration, sourceFileResolver));
+ }
+
+ return violations;
+ }
+
+ private List evaluate(final NamedArchRule namedRule, final JavaClasses javaClasses,
+ final ArchUnitConfiguration configuration, final SourceFileResolver sourceFileResolver) {
+ final EvaluationResult result = namedRule.rule()
+ .evaluate(javaClasses);
+
+ if (!result.hasViolation()) {
+ return List.of();
+ }
+
+ final List violations = new ArrayList<>();
+
+ /*
+ * An anonymous class rather than a lambda: ArchUnit derives the type it filters the corresponding objects by
+ * from the reified varargs array, which a lambda does not provide. Typed as Object so every corresponding
+ * object is handed over, whether it is a class, a member or an access.
+ */
+ result.handleViolations(new ViolationHandler