Skip to content

Commit bf3653d

Browse files
SirCotareclaude
andauthored
Make every rule able to fail, and prove it (#4)
* add a red/green fixture test for every shipped rule Each of the 13 rules gets a fixture built to violate it and one built to satisfy it, asserted on the violation count and message rather than on the rule having run. The library had no such test, which is why rules that match nothing have been reporting success. This commit is deliberately red: nine tests fail. Eight fixtures that must produce a violation do not. - test classes with no production counterpart (rule is fully dead) - a blacklisted annotation on a constructor parameter - a blacklisted method call from a constructor - a blacklisted method call from an instance field initializer - System.out from a constructor - a @nested test class whose production class is missing entirely - a controller method covered only by a longer-named sibling's @nested class - a non-static SortMappings field The ninth is not a fixture: the blacklist names three AssertJ methods that do not exist, so those entries can never match. Fixtures live outside it.aboutbits.archunit.toolbox so the project's own ArchitectureTest does not analyse them, and are excluded from surefire because some are named *Test. Third-party types the blacklists name are stubbed rather than depended on: spring-boot-toolbox depends on archunit-toolbox, so SortMappings, @Store and @ArchAllowDirectAccess cannot come from there. The stub contracts were verified against the real 2.5.2 artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * match test class names and code unit bodies in one shared place Two root causes, each shared by several rules. The suffix regex was hand-built in five places, in two shapes, against two different inputs. The counterpart rule's condition rebuilt it without the leading ".+" and matched it against getSimpleName() with String.matches, which anchors both ends - so only a class named exactly "Test" got past the guard and every real test class returned before the counterpart lookup ever ran. The rule has therefore never reported anything. TestClassNames now owns the pattern, the stripping and the selection predicate. The redundant guard in the condition is gone rather than corrected: the selection already guarantees the suffix, and re-deriving it in the condition is what let the two drift apart. Selection also matches the simple name and requires a non-empty production name to be left over, so it can no longer accept a name the stripping turns into nothing ("CacheTest" matched with "Cache" as the ".+"). Three rules walked getMethods(), which excludes constructors - and with them every instance field initializer, since that is compiled into the constructor. So a blacklisted annotation on a constructor parameter, the canonical Lombok position, was invisible. All three now walk getCodeUnits(), which also subsumes the separate static-initializer branches two of them carried. Also drops three blacklist entries naming AssertJ methods that do not exist (assertThrows, assertThrowsExactly, assertDoesNotThrow are JUnit's). An entry that can never match reads as coverage without providing any. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * report what a rule cannot verify instead of passing Removes the remaining ways a rule stayed quiet. allowEmptyShould(true) is gone everywhere. A rule that selects nothing reported success, indistinguishable from a rule that is satisfied, which is what let a dead rule survive. EmptySelectionTest pins this for all eight rules that narrow their input. Consequence to be aware of: CommonArchRuleCollection now fails in a project with no controllers or no @Store classes, so implement it only where those exist. The @nested name rule skipped a nested class silently whenever the production class was missing and the nested class was not inside a group - so the case with nothing to compare against was the one case never reported. It now reports, and in exchange honours @ArchIgnoreNoProductionCounterpart: a test class that declares it has no production counterpart has no production methods either. The security-test rule accepted any @nested class *starting with* the method name, so getAll() counted as covered by GetAllArchived. It now requires the name to match exactly or to continue with "$", which keeps grouped @nested classes working. The SortMappings rule swallowed every failure to read a field into a log.warn - and the build has no SLF4J provider, so the warning was discarded outright. A non-static field, an unreadable field, an unresolvable enum type and a value that is not a Map are all violations now. Non-static in particular is reported as such, since it can never be validated. Finally, @ArchIgnoreNoProductionCounterpart and @ArchIgnoreGroupName no longer meta-annotate ArchUnit's @ArchIgnore. The ArchUnit JUnit engine resolves meta-annotations, so annotating a test class skipped *every* @archtest on it and still reported BUILD SUCCESS - verified: putting it on this project's own ArchitectureTest turned 11 rules into 11 skips. Both annotations are read by their own type, so the meta-annotation bought nothing. ArchitectureTest now carries it in place of the name that was hardcoded into the counterpart rule, which also keeps the annotation exercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * document the current API and the opt-out annotations The usage section still described the 1.1.0 API (extends ArchitectureTestBase, a BLACKLISTED_CLASSES static block), which no longer exists. Replaces it with the rule collections, records why no rule allows an empty selection, and documents both opt-out annotations - neither of which any consumer appears to use yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * allow rules a project has no code for, and guard the import instead Failing on an empty selection was the wrong call. Seven of the eight rules that narrow their input have a legitimate empty case - no records, no controllers, no @Store classes, nobody using @nested - and one of them described this repository before this branch: a project whose only test is the ArchitectureTest has no @test methods at all. Whether that code exists is the project's business. allowEmptyShould(true) was never what protected against a dead rule either. It fires on what a consumer's code happens to contain and says nothing about whether a rule's logic works: the counterpart rule that started this had a perfectly non-empty selection and a broken condition. The guarantee is the red test per rule in this project, so all 13 rules now tolerate an empty selection. That leaves one case worth failing on. A mistyped or moved package in @AnalyzeClasses imports nothing, and every rule then passes without looking at a single class. AnalyzedPackagesMustContainClassesArchRule turns that into one failure naming the cause, instead of five rules passing silently and eight reporting that they checked nothing. It is in both collections, so it applies wherever the toolbox is used. EmptySelectionTest is inverted to match: it now pins that every rule accepts a project with nothing for it to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * use markdown javadoc Converts every documentation comment to the /// form of JEP 467: 54 javadoc comments plus two block comments that already sat in documentation positions. Markdown idiom throughout - `code` for {@code}, [Type#member] for {@link}, blank /// lines for <p>, and backticked annotation names for the &#64; entities. The five comments inside method bodies stay /* */, since /// is a documentation comment form and those document statements rather than declarations. No behaviour change. Verified with javadoc -Xdoclint:all: no warnings, the [#member] references resolve, and the generated HTML shows code spans, paragraphs and links rather than literal markdown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * make the opt-outs usable once, and close the blacklist hole Addresses the review on #4, all three points verified against reverts. The opt-outs are read with areNotMetaAnnotatedWith / isMetaAnnotatedWith instead of the direct-only variants, so a project carries @ArchIgnoreNoProductionCounterpart on one stereotype of its own rather than repeating it on 74 classes. ArchUnit counts a direct annotation as meta-annotated, so annotating a single class still works. @ArchIgnoreGroupName gets the same treatment in all three places it is read: it is the sibling opt-out with the identical ergonomics problem, and leaving it direct-only would be half a fix. Architecture tests are exempted from the production-counterpart rule by package, via .._architecture.. alongside .._support.. / .._config.., so dropping the hardcoded "ArchitectureTest" name does not push boilerplate onto every consumer. Deliberately not added to TestClassVisibilityArchRule: being package private is as achievable for an architecture test as for any other test, so the two exclusion lists encode different facts and are meant to differ. org.junit.jupiter.api.Assertions.assertThrowsExactly is blacklisted again. It only ever existed under the AssertJ namespace that the previous commit removed, so the cleanup dropped the house rule along with the bogus entry. Now covered by a fixture that actually calls it, not only by a list assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * use a multiline string for the empty-import message Review feedback on #4: text block instead of concatenation. The message now reads over two lines, which also matches how the other rules format multi-line output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * address review: lookup cost, null mappings, and an upgrade note Four review comments on #4. The two counterpart lookups scanned every imported class per test class. JavaClasses is map-backed by fully qualified name, so both are now contain()/get(). New fixture nestedclassname.goodgroup pins that a production class nested inside another is still found, since its name contains a '$' and nothing covered that before - without it a keying difference would have passed silently, the badgroup fixture expecting a violation either way. SortMappings gets the null-value fixture: a static field that reads back as null yields nothing to compare, so the rule reports rather than calling it exhaustive. The undeterminable-enum and non-enum-key paths remain untested. Readme gains an upgrade note naming what breaks when a consumer moves off 1.2.0 - revived rules surfacing real violations, and the new empty-import failure - and the opt-out section now says that @disabled and @ArchIgnore are matched through meta-annotations too, so a stereotype carrying either exempts its classes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8aee4f1 commit bf3653d

132 files changed

Lines changed: 2413 additions & 307 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pom.xml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,32 @@
5050
<version>1.4.2</version>
5151
<scope>compile</scope>
5252
</dependency>
53+
54+
<!-- Test only: needed to run this library's own rule tests -->
55+
<dependency>
56+
<groupId>org.junit.jupiter</groupId>
57+
<artifactId>junit-jupiter-engine</artifactId>
58+
<scope>test</scope>
59+
</dependency>
60+
<dependency>
61+
<groupId>org.assertj</groupId>
62+
<artifactId>assertj-core</artifactId>
63+
<scope>test</scope>
64+
</dependency>
65+
<!--
66+
Fixtures for the two rules in CommonArchRuleCollection need real Spring MVC annotations.
67+
Test scope only, so consumers do not inherit them.
68+
-->
69+
<dependency>
70+
<groupId>org.springframework</groupId>
71+
<artifactId>spring-web</artifactId>
72+
<scope>test</scope>
73+
</dependency>
74+
<dependency>
75+
<groupId>org.springframework</groupId>
76+
<artifactId>spring-context</artifactId>
77+
<scope>test</scope>
78+
</dependency>
5379
</dependencies>
5480

5581
<build>
@@ -98,6 +124,20 @@
98124
<fork>true</fork>
99125
</configuration>
100126
</plugin>
127+
<plugin>
128+
<groupId>org.apache.maven.plugins</groupId>
129+
<artifactId>maven-surefire-plugin</artifactId>
130+
<configuration>
131+
<!--
132+
it.aboutbits.archunit.fixture contains deliberately non-conforming classes that
133+
the rule tests import as input. Some are named *Test and would otherwise be
134+
collected and reported as tests of this project.
135+
-->
136+
<excludes>
137+
<exclude>it/aboutbits/archunit/fixture/**</exclude>
138+
</excludes>
139+
</configuration>
140+
</plugin>
101141
<plugin>
102142
<groupId>org.apache.maven.plugins</groupId>
103143
<artifactId>maven-checkstyle-plugin</artifactId>

readme.md

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,34 +16,109 @@ Add this library to the classpath by adding the following maven dependency. Vers
1616
</dependency>
1717
```
1818

19+
## Upgrading to 1.3.0
20+
21+
**This release will fail builds that passed on 1.2.0, on purpose.** Nine rules were silently
22+
reporting success because they matched nothing; fixing them turns real violations into build
23+
failures for the first time. Expect two kinds:
24+
25+
- **Revived rules surface real violations.** Chiefly the two that were fully dead:
26+
`test_classes_should_be_in_the_same_package_as_their_production_code` and
27+
`nested_test_classes_have_matching_production_method_name`. Triage them with the opt-out
28+
stereotype described under [Opting out](#opting-out) before annotating classes one at a time.
29+
The stricter paths (`getCodeUnits()` reaching constructors and field initializers, exact
30+
security-test matching, `SortMappings` reporting what it cannot read) surfaced nothing in a
31+
large codebase, so noise from those is unlikely.
32+
- **`analyzed_packages_must_contain_classes` is new and fails on an empty import.** If the
33+
packages given to `@AnalyzeClasses` are mistyped or have moved, that is now a failure instead
34+
of 13 rules quietly passing.
35+
36+
Nothing else needs a migration: no rule fails over code your project does not have.
37+
1938
## Usage
2039

21-
To use this package, simply extend one of the provided ArchUnit classes.
22-
For example `ArchitectureTestBase`:
40+
Implement one of the provided rule collections in your own architecture test.
2341

2442
```java
2543

2644
@AnalyzeClasses(
2745
packages = ArchitectureTest.PACKAGE
2846
)
2947
@NullMarked
30-
class ArchitectureTest extends ArchitectureTestBase {
48+
@ArchIgnoreNoProductionCounterpart
49+
class ArchitectureTest implements BaseArchRuleCollection {
3150
static final String PACKAGE = "the.base.package.of.your.project";
51+
}
52+
```
3253

33-
static {
34-
// Configuration
35-
}
54+
`BaseArchRuleCollection` holds the rules that apply to any Java project. `CommonArchRuleCollection`
55+
adds rules for Spring MVC controllers and for `SortMappings`, so implement it only in a project that
56+
has them.
57+
58+
The blacklists are mutable, so a project can drop an entry it disagrees with:
59+
60+
```java
61+
62+
static {
63+
BlacklistClassesArchRule.BLACKLISTED_CLASSES.remove("net.datafaker.Faker");
3664
}
3765
```
3866

39-
In the static block you can configure some blacklists provided by the base class.
67+
The same applies to `ArchRuleConfig.TEST_CLASS_SUFFIXES` when a project introduces a new test type.
68+
69+
### Rules your project has no code for
70+
71+
Every rule tolerates a selection that comes up empty, so a rule simply passes on a project it does
72+
not apply to. Whether a project has records, controllers, `@Store` classes or `@Nested` test classes
73+
is the project's business, not something this library requires.
74+
75+
That each rule can actually fail is guaranteed by a red test per rule in this repository, rather than
76+
by making your build fail over code you do not have. An empty selection says nothing about whether a
77+
rule's logic works.
78+
79+
One case is a real problem though, and `analyzed_packages_must_contain_classes` covers it: if the
80+
packages given to `@AnalyzeClasses` are mistyped or have moved, nothing is imported and every other
81+
rule would pass without looking at a single class. That fails, once, with a message naming the cause.
82+
83+
### Opting out
84+
85+
Two annotations exempt a class from a specific rule. Neither is meta-annotated with ArchUnit's
86+
`@ArchIgnore`: the ArchUnit JUnit engine resolves meta-annotations, so that would skip *every*
87+
`@ArchTest` on the annotated class and report success rather than exempting it from one rule.
88+
89+
| annotation | put it on | exempts from |
90+
|---|---|---|
91+
| `@ArchIgnoreNoProductionCounterpart` | a test class | needing a production class of the same name in the same package, and having its `@Nested` classes matched against production methods |
92+
| `@ArchIgnoreGroupName` | a `@Nested` test class | needing a production method of the same name, for a class that only groups tests |
93+
94+
Use `@ArchIgnoreNoProductionCounterpart` for a test named after the behaviour it describes rather than
95+
after a production class.
96+
97+
Both are read as meta-annotations, so a project declares its intent once on its own stereotype instead
98+
of repeating the annotation on every class:
4099

41100
```java
42-
static {
43-
ArchitectureTestBase.BLACKLISTED_CLASSES.remove("net.datafaker.Faker");
101+
102+
@Target(ElementType.TYPE)
103+
@Retention(RetentionPolicy.RUNTIME)
104+
@ArchIgnoreNoProductionCounterpart
105+
public @interface BusinessTest {
44106
}
45107
```
46108

109+
Annotating a single class directly still works — ArchUnit counts a direct annotation as
110+
meta-annotated.
111+
112+
The same applies to `@Disabled` and ArchUnit's `@ArchIgnore`, which these rules also honour: a
113+
stereotype that carries either of them exempts every class using it. That matches how JUnit and the
114+
ArchUnit engine themselves read those two annotations — a class whose tests do not run is not held to
115+
naming rules — but it does mean a stereotype can exempt more than it appears to, so keep an eye on
116+
what your own test annotations carry.
117+
118+
Architecture tests need neither: any class in a package named `_architecture` is exempt from the
119+
production-counterpart rule, alongside the existing `_support` and `_config` exclusions. Use the
120+
annotation for the one-off that lives elsewhere.
121+
47122
## Local Development
48123

49124
To use this library as a local development dependency, you can simply refer to the version `BUILD-SNAPSHOT`.

src/main/java/it/aboutbits/archunit/toolbox/BaseArchRuleCollection.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package it.aboutbits.archunit.toolbox;
22

3+
import it.aboutbits.archunit.toolbox.rule.base.AnalyzedPackagesMustContainClassesArchRule;
34
import it.aboutbits.archunit.toolbox.rule.base.BlacklistAnnotationsArchRule;
45
import it.aboutbits.archunit.toolbox.rule.base.BlacklistClassesArchRule;
56
import it.aboutbits.archunit.toolbox.rule.base.BlacklistMethodsArchRule;
@@ -15,6 +16,7 @@
1516

1617
@NullMarked
1718
public interface BaseArchRuleCollection extends
19+
AnalyzedPackagesMustContainClassesArchRule,
1820
BlacklistAnnotationsArchRule,
1921
BlacklistClassesArchRule,
2022
BlacklistMethodsArchRule,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package it.aboutbits.archunit.toolbox;
22

3+
import it.aboutbits.archunit.toolbox.rule.base.AnalyzedPackagesMustContainClassesArchRule;
34
import it.aboutbits.archunit.toolbox.rule.common.ControllerRequestMappingsMustBeSecurityTested;
45
import it.aboutbits.archunit.toolbox.rule.common.SortMappingsExhaustiveArchRule;
56
import org.jspecify.annotations.NullMarked;
67

78
@NullMarked
89
public interface CommonArchRuleCollection extends
10+
AnalyzedPackagesMustContainClassesArchRule,
911
ControllerRequestMappingsMustBeSecurityTested,
1012
SortMappingsExhaustiveArchRule {
1113
}

src/main/java/it/aboutbits/archunit/toolbox/config/ArchRuleConfig.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ public final class ArchRuleConfig {
1010
private ArchRuleConfig() {
1111
}
1212

13-
/**
14-
* List of supported test class name suffixes.
15-
* <p>
16-
* When introducing a new test type (e.g. IntegrationTest), add its suffix here
17-
* instead of directly modifying the regex pattern.
18-
**/
13+
/// List of supported test class name suffixes.
14+
///
15+
/// When introducing a new test type (e.g. IntegrationTest), add its suffix here
16+
/// instead of directly modifying the regex pattern.
1917
public static final Set<String> TEST_CLASS_SUFFIXES = new HashSet<>(
2018
Set.of(
2119
"Test",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package it.aboutbits.archunit.toolbox.rule.base;
2+
3+
import com.tngtech.archunit.core.domain.JavaClasses;
4+
import com.tngtech.archunit.junit.ArchTest;
5+
import org.jspecify.annotations.NullMarked;
6+
7+
/// Checks that the analyzed packages contain any classes at all.
8+
///
9+
/// Every other rule tolerates an empty selection, because whether a project has records, controllers
10+
/// or `@Nested` test classes is the project's business and not something this library gets to require.
11+
/// That leaves exactly one dangerous case: a mistyped or moved package in `@AnalyzeClasses` imports
12+
/// nothing, and every rule then passes without looking at a single class. This rule is what turns that
13+
/// into a failure, once, with a message that names the actual problem.
14+
@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"})
15+
@NullMarked
16+
public interface AnalyzedPackagesMustContainClassesArchRule {
17+
@SuppressWarnings({"unused", "checkstyle:MethodName", "java:S100"})
18+
@ArchTest
19+
default void analyzed_packages_must_contain_classes(JavaClasses classes) {
20+
if (classes.isEmpty()) {
21+
throw new AssertionError("""
22+
No classes were imported, so none of the architecture rules checked anything.
23+
Verify the packages passed to @AnalyzeClasses.""");
24+
}
25+
}
26+
}

src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistAnnotationsArchRule.java

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.util.Set;
1313

1414
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
15+
import static it.aboutbits.archunit.toolbox.util.CodeUnitUtil.describeKind;
1516
import static it.aboutbits.archunit.toolbox.util.LineNumberUtil.getLineNumber;
1617

1718
@SuppressWarnings({"checkstyle:InterfaceIsType", "java:S1214"})
@@ -63,6 +64,7 @@ public interface BlacklistAnnotationsArchRule {
6364
default void no_blacklisted_annotations_are_used(JavaClasses classes) {
6465
classes()
6566
.should(new NotUseBlacklistedAnnotations())
67+
.allowEmptyShould(true)
6668
.check(classes);
6769
}
6870

@@ -87,33 +89,36 @@ public void check(JavaClass javaClass, ConditionEvents events) {
8789
}
8890
}
8991

90-
// Check annotations on methods and their parameters
91-
for (var method : javaClass.getMethods()) {
92-
// Check method annotations
93-
for (var annotation : method.getAnnotations()) {
92+
// getCodeUnits() covers methods, constructors and the static initializer. getMethods()
93+
// would miss constructors, and with them the most common position of all: a blacklisted
94+
// annotation on a constructor parameter.
95+
for (var codeUnit : javaClass.getCodeUnits()) {
96+
for (var annotation : codeUnit.getAnnotations()) {
9497
if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) {
9598
var message = String.format(
96-
"Method %s is annotated with blacklisted annotation @%s (%s.java:%d)",
97-
method.getFullName(),
99+
"%s %s is annotated with blacklisted annotation @%s (%s.java:%d)",
100+
describeKind(codeUnit),
101+
codeUnit.getFullName(),
98102
annotation.getRawType().getFullName(),
99103
javaClass.getSimpleName(),
100-
getLineNumber(method)
104+
getLineNumber(codeUnit)
101105
);
102-
events.add(SimpleConditionEvent.violated(method, message));
106+
events.add(SimpleConditionEvent.violated(codeUnit, message));
103107
}
104108
}
105-
// Check method parameter annotations
106-
for (var parameter : method.getParameters()) {
109+
110+
for (var parameter : codeUnit.getParameters()) {
107111
for (var annotation : parameter.getAnnotations()) {
108112
if (BLACKLISTED_ANNOTATIONS.contains(annotation.getRawType().getFullName())) {
109113
var message = String.format(
110-
"Parameter %s of method %s is annotated with blacklisted annotation @%s (%s.java:%d)",
114+
"Parameter %s of %s %s is annotated with blacklisted annotation @%s (%s.java:%d)",
111115
parameter.getIndex(),
112-
method.getFullName(),
116+
describeKind(codeUnit).toLowerCase(java.util.Locale.ROOT),
117+
codeUnit.getFullName(),
113118
annotation.getRawType().getFullName(),
114119
javaClass.getSimpleName(),
115-
getLineNumber(method)
116-
); // Parameter doesn't have its own SLOC, use method's
120+
getLineNumber(codeUnit)
121+
); // Parameter doesn't have its own SLOC, use the code unit's
117122
events.add(SimpleConditionEvent.violated(parameter, message));
118123
}
119124
}

src/main/java/it/aboutbits/archunit/toolbox/rule/base/BlacklistClassesArchRule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public boolean test(JavaClass javaClass) {
3535
}
3636
}
3737
)
38+
.allowEmptyShould(true)
3839
.check(classes);
3940
}
4041
}

0 commit comments

Comments
 (0)