Skip to content

Make every rule able to fail, and prove it - #4

Open
SirCotare wants to merge 7 commits into
mainfrom
claude/archunit-toolbox-silent-rules-35a6e2
Open

Make every rule able to fail, and prove it#4
SirCotare wants to merge 7 commits into
mainfrom
claude/archunit-toolbox-silent-rules-35a6e2

Conversation

@SirCotare

@SirCotare SirCotare commented Aug 19, 2026

Copy link
Copy Markdown
Member

Target Version: 1.3.0

Why

test_classes_should_be_in_the_same_package_as_their_production_code has never reported anything, in any project: the condition rebuilt the suffix regex without the leading .+, and String.matches anchors both ends, so every real test class returned before the counterpart lookup ran. Auditing the other 12 rules against purpose-built fixtures found eight more silent passes — a rule that matches nothing reports success.

What changed

Every rule now has a fixture built to violate it and one built to satisfy it, asserted on violation count and message. Nine defects fixed, and the suffix regex and code-unit iteration each moved into one shared, tested place.

Proving a rule can fail is the job of these tests, not of consumers' builds, so every rule tolerates a selection that does not apply to the project. The one case that genuinely must fail is a mistyped @AnalyzeClasses package importing nothing, which previously let five rules pass silently while eight reported that they checked nothing. AnalyzedPackagesMustContainClassesArchRule now turns that into one failure naming the cause.

Before / after

before after
rules with a test proving they can fail 0 13
confirmed silent-pass defects 9 0
rules that fail on a project they do not apply to 3 0
rules checking nothing in this repo's own green build 5 of 11 0
tests 11 89

Review notes

  • The worst defect was not the regex. @ArchIgnoreNoProductionCounterpart was meta-annotated @ArchIgnore, which ArchUnit's JUnit engine resolves — so putting it on a test class skipped every @ArchTest on that class and still reported BUILD SUCCESS. Verified on this repo's own ArchitectureTest: 12 rules became 12 skips. That fix needs its own guard test, because skipped tests do not fail a build.
  • Revived rules will surface real violations in consumers; finstral-composer-api has ~71 test classes needing @ArchIgnoreNoProductionCounterpart. I rejected adding .._business.. to the library's excluded packages, as that bakes one project's convention into shared tooling.
  • TestClassNames rebuilds its regex per call on purpose: caching it would silently break consumers that mutate TEST_CLASS_SUFFIXES.
  • Third-party types are stubbed rather than depended on — spring-boot-toolbox depends on this library. Stub contracts were verified against the real 2.5.2 jar.
  • Commit 1 is deliberately red (9 failures), so the history reads as audit → fix.
  • Doc comments moved to the /// markdown form in their own commit, no behaviour change.
  • Review follow-up: opt-outs are now read as meta-annotations so a project declares one stereotype instead of annotating 74 classes; .._architecture.. exempts arch tests from the counterpart rule (deliberately not from the visibility rule — the two lists encode different facts); assertThrowsExactly re-blacklisted under its real JUnit owner.
  • Three findings outside this diff (dead LineNumberUtil API, two limits of the record-accessor rule) are noted in the session, not addressed here.

Tests

mvn clean test: 81 pass, 0 skipped, checkstyle and NullAway clean. Each of the eleven fixes was additionally validated by reverting it and confirming a named test fails. Not run: any consumer project build.

🤖 Generated with Claude Code

SirCotare and others added 4 commits August 19, 2026 12:32
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>
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>
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>
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>
@SirCotare SirCotare self-assigned this Aug 19, 2026
SirCotare and others added 2 commits August 19, 2026 16:42
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>
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>
SirCotare

This comment was marked as outdated.

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>
SirCotare

This comment was marked as resolved.

@SirCotare
SirCotare requested a review from ThoSap August 20, 2026 07:47
@SirCotare

Copy link
Copy Markdown
Member Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant