From 7646b1f332a89b45e6f54ca2ee8b5110091d957b Mon Sep 17 00:00:00 2001 From: chadongmin Date: Sat, 15 Nov 2025 18:05:56 +0900 Subject: [PATCH 1/3] improve error messages for synthetic/anonymous class violations When ArchUnit rules fail on synthetic or anonymous classes generated by the compiler (e.g., from lambdas or enum switches), new users are often confused by error messages pointing to classes they didn't write (MyService$1). This commit enhances naming convention error messages to detect synthetic/ anonymous classes and provide a helpful hint directing users to exclude these classes using .doNotHaveModifier(JavaModifier.SYNTHETIC) or .areNotAnonymousClasses(). The implementation uses a custom ArchCondition that checks the failing object and appends the hint only when the condition fails on a synthetic or anonymous class, ensuring regular violations remain unchanged. Implementation details: - Checks both SYNTHETIC (compiler-generated, e.g., enum switch maps per JLS 13.1.7) and anonymous classes (both can cause unexpected naming violations) - Applies to haveSimpleNameStartingWith, haveSimpleNameContaining, and haveSimpleNameEndingWith methods - Fully compatible with Java 8-21 (all APIs available since Java 1.5) - Includes unit tests for anonymous classes and integration tests for synthetic classes Resolves: #1509 Signed-off-by: chadongmin --- .../integration/ExamplesIntegrationTest.java | 5 +++ .../archunit/testutils/ExpectedNaming.java | 21 ++++++++- .../lang/conditions/ArchConditions.java | 45 +++++++++++++++++-- .../syntax/elements/ClassesShouldTest.java | 29 ++++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/archunit-integration-test/src/test/java/com/tngtech/archunit/integration/ExamplesIntegrationTest.java b/archunit-integration-test/src/test/java/com/tngtech/archunit/integration/ExamplesIntegrationTest.java index a224025da5..500858cafd 100644 --- a/archunit-integration-test/src/test/java/com/tngtech/archunit/integration/ExamplesIntegrationTest.java +++ b/archunit-integration-test/src/test/java/com/tngtech/archunit/integration/ExamplesIntegrationTest.java @@ -1391,6 +1391,11 @@ Stream NamingConventionTest() { .by(simpleNameOf(WronglyAnnotated.class).notEndingWith("Controller")) .by(simpleNameOf(SomeEnum.class).notEndingWith("Controller")) .by(simpleNameOfAnonymousClassOf(UseCaseOneThreeController.class).notEndingWith("Controller")) + .by(violation("")) + .by(violation("Hint: The failing class appears to be a synthetic or anonymous class generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). To exclude these from your rule, consider adding:")) + .by(violation(" .that().doNotHaveModifier(JavaModifier.SYNTHETIC)")) + .by(violation("or:")) + .by(violation(" .that().areNotAnonymousClasses()")) .ofRule("classes that have simple name containing 'Controller' should reside in a package '..controller..'") .by(javaClass(AbstractController.class).notResidingIn("..controller..")) diff --git a/archunit-integration-test/src/test/java/com/tngtech/archunit/testutils/ExpectedNaming.java b/archunit-integration-test/src/test/java/com/tngtech/archunit/testutils/ExpectedNaming.java index 698000927c..47f385e84b 100644 --- a/archunit-integration-test/src/test/java/com/tngtech/archunit/testutils/ExpectedNaming.java +++ b/archunit-integration-test/src/test/java/com/tngtech/archunit/testutils/ExpectedNaming.java @@ -10,12 +10,26 @@ public static Creator simpleNameOfAnonymousClassOf(Class clazz) { } public static class Creator { + private static final String SYNTHETIC_CLASS_HINT = + "\n\nHint: The failing class appears to be a synthetic or anonymous class " + + "generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " + + "To exclude these from your rule, consider adding:\n" + + " .that().doNotHaveModifier(JavaModifier.SYNTHETIC)\n" + + "or:\n" + + " .that().areNotAnonymousClasses()"; + private final String className; private final String simpleName; + private final boolean isAnonymous; private Creator(String className, String simpleName) { + this(className, simpleName, className.contains("$")); + } + + private Creator(String className, String simpleName, boolean isAnonymous) { this.className = className; this.simpleName = simpleName; + this.isAnonymous = isAnonymous; } public ExpectedMessage notStartingWith(String prefix) { @@ -31,8 +45,11 @@ public ExpectedMessage containing(String infix) { } private ExpectedMessage expectedClassViolation(String description) { - return new ExpectedMessage(String.format("Class <%s> %s in (%s.java:0)", - className, description, simpleName)); + String message = String.format("Class <%s> %s in (%s.java:0)", + className, description, simpleName); + // Note: Hint message is not included in expected message for integration tests + // The hint feature is tested separately in unit tests (ClassesShouldTest) + return new ExpectedMessage(message); } } } diff --git a/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java b/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java index b0be5bdf02..b0658dacb1 100644 --- a/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java +++ b/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java @@ -530,7 +530,7 @@ public static ArchCondition notHaveSimpleName(String name) { @PublicAPI(usage = ACCESS) public static ArchCondition haveSimpleNameStartingWith(String prefix) { - return have(simpleNameStartingWith(prefix)); + return haveWithHint(simpleNameStartingWith(prefix)); } @PublicAPI(usage = ACCESS) @@ -540,7 +540,7 @@ public static ArchCondition haveSimpleNameNotStartingWith(String pref @PublicAPI(usage = ACCESS) public static ArchCondition haveSimpleNameContaining(String infix) { - return have(simpleNameContaining(infix)); + return haveWithHint(simpleNameContaining(infix)); } @PublicAPI(usage = ACCESS) @@ -550,7 +550,7 @@ public static ArchCondition haveSimpleNameNotContaining(String infix) @PublicAPI(usage = ACCESS) public static ArchCondition haveSimpleNameEndingWith(String suffix) { - return have(simpleNameEndingWith(suffix)); + return haveWithHint(simpleNameEndingWith(suffix)); } @PublicAPI(usage = ACCESS) @@ -1308,6 +1308,45 @@ public static ConditionByPred .describeEventsBy((predicateDescription, satisfied) -> (satisfied ? "has " : "does not have ") + predicateDescription); } + private static final String SYNTHETIC_CLASS_HINT_MESSAGE = + "\n\nHint: The failing class appears to be a synthetic or anonymous class " + + "generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " + + "To exclude these from your rule, consider adding:\n" + + " .that().doNotHaveModifier(JavaModifier.SYNTHETIC)\n" + + "or:\n" + + " .that().areNotAnonymousClasses()"; + + /** + * Like {@link #have(DescribedPredicate)}, but adds a helpful hint when the condition fails on synthetic or anonymous classes. + * This helps new users understand that compiler-generated classes can be excluded from rules. + * @param predicate The predicate determining which objects satisfy/violate the condition + * @return An {@link ArchCondition} that provides hints for failures on synthetic/anonymous classes + */ + private static ArchCondition haveWithHint(DescribedPredicate predicate) { + return new ArchCondition(ArchPredicates.have(predicate).getDescription()) { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean satisfied = predicate.test(javaClass); + String baseMessage = (satisfied ? "has " : "does not have ") + predicate.getDescription(); + + String message; + if (!satisfied && isSyntheticOrAnonymous(javaClass)) { + message = javaClass.getDescription() + " " + baseMessage + + " in " + javaClass.getSourceCodeLocation() + SYNTHETIC_CLASS_HINT_MESSAGE; + } else { + message = javaClass.getDescription() + " " + baseMessage + + " in " + javaClass.getSourceCodeLocation(); + } + + events.add(new SimpleConditionEvent(javaClass, satisfied, message)); + } + }; + } + + private static boolean isSyntheticOrAnonymous(JavaClass javaClass) { + return javaClass.getModifiers().contains(JavaModifier.SYNTHETIC) || javaClass.isAnonymousClass(); + } + /** * Derives an {@link ArchCondition} from a {@link DescribedPredicate}. Similar to {@link ArchCondition#from(DescribedPredicate)}, * but more conveniently creates a message to be used within a 'be'-sentence. diff --git a/archunit/src/test/java/com/tngtech/archunit/lang/syntax/elements/ClassesShouldTest.java b/archunit/src/test/java/com/tngtech/archunit/lang/syntax/elements/ClassesShouldTest.java index db74bdfae1..8f5524214f 100644 --- a/archunit/src/test/java/com/tngtech/archunit/lang/syntax/elements/ClassesShouldTest.java +++ b/archunit/src/test/java/com/tngtech/archunit/lang/syntax/elements/ClassesShouldTest.java @@ -372,6 +372,35 @@ public void haveSimpleNameNotEndingWith(ArchRule rule, String suffix) { .doesNotContain(SomeClass.class.getName()); } + @Test + public void haveSimpleNameEndingWith_should_show_hint_for_anonymous_classes() { + Class anonymousClass = NestedClassWithSomeMoreClasses.getAnonymousClass(); + + ArchRule rule = classes() + .should().haveSimpleNameEndingWith("SomethingElse"); + + EvaluationResult result = rule.evaluate(importClasses(anonymousClass)); + + assertThat(singleLineFailureReportOf(result)) + .contains("does not have simple name ending with 'SomethingElse'") + .contains("Hint:") + .contains("synthetic or anonymous") + .contains("doNotHaveModifier(JavaModifier.SYNTHETIC)") + .contains("areNotAnonymousClasses()"); + } + + @Test + public void haveSimpleNameEndingWith_should_NOT_show_hint_for_regular_classes() { + ArchRule rule = classes() + .should().haveSimpleNameEndingWith("ValidSuffix"); + + EvaluationResult result = rule.evaluate(importClasses(WrongNamedClass.class)); + + assertThat(singleLineFailureReportOf(result)) + .contains("does not have simple name ending with 'ValidSuffix'") + .doesNotContain("Hint:"); + } + @DataProvider public static Object[][] resideInAPackage_rules() { String thePackage = ArchRule.class.getPackage().getName(); From d49621ace3894823a007ec0cb4278fdfa6a72624 Mon Sep 17 00:00:00 2001 From: chadongmin Date: Sat, 15 Nov 2025 18:48:19 +0900 Subject: [PATCH 2/3] use platform-specific line separator for hint messages The synthetic class hint message was using hardcoded \n line separators, which caused test failures on Windows CI. Windows uses \r\n as line separator, but the test framework splits violation messages using System.lineSeparator(). This mismatch prevented proper message parsing and matching in integration tests. Changed SYNTHETIC_CLASS_HINT_MESSAGE from a static constant with hardcoded \n to a dynamic method getSyntheticClassHintMessage() that uses System.lineSeparator(). This ensures the hint message uses the correct platform-specific line separator, allowing tests to pass on all platforms (macOS, Linux, and Windows). Signed-off-by: chadongmin --- .../lang/conditions/ArchConditions.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java b/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java index b0658dacb1..1109352f93 100644 --- a/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java +++ b/archunit/src/main/java/com/tngtech/archunit/lang/conditions/ArchConditions.java @@ -1308,13 +1308,15 @@ public static ConditionByPred .describeEventsBy((predicateDescription, satisfied) -> (satisfied ? "has " : "does not have ") + predicateDescription); } - private static final String SYNTHETIC_CLASS_HINT_MESSAGE = - "\n\nHint: The failing class appears to be a synthetic or anonymous class " + - "generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " + - "To exclude these from your rule, consider adding:\n" + - " .that().doNotHaveModifier(JavaModifier.SYNTHETIC)\n" + - "or:\n" + - " .that().areNotAnonymousClasses()"; + private static String getSyntheticClassHintMessage() { + String lineSeparator = System.lineSeparator(); + return lineSeparator + lineSeparator + "Hint: The failing class appears to be a synthetic or anonymous class " + + "generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " + + "To exclude these from your rule, consider adding:" + lineSeparator + + " .that().doNotHaveModifier(JavaModifier.SYNTHETIC)" + lineSeparator + + "or:" + lineSeparator + + " .that().areNotAnonymousClasses()"; + } /** * Like {@link #have(DescribedPredicate)}, but adds a helpful hint when the condition fails on synthetic or anonymous classes. @@ -1332,7 +1334,7 @@ public void check(JavaClass javaClass, ConditionEvents events) { String message; if (!satisfied && isSyntheticOrAnonymous(javaClass)) { message = javaClass.getDescription() + " " + baseMessage + - " in " + javaClass.getSourceCodeLocation() + SYNTHETIC_CLASS_HINT_MESSAGE; + " in " + javaClass.getSourceCodeLocation() + getSyntheticClassHintMessage(); } else { message = javaClass.getDescription() + " " + baseMessage + " in " + javaClass.getSourceCodeLocation(); From 5a118035fc91ba2d3c9a2f7b3611b316d4845445 Mon Sep 17 00:00:00 2001 From: chadongmin Date: Wed, 26 Nov 2025 22:09:41 +0900 Subject: [PATCH 3/3] make FuzzyViolationLineMatcher ignore hint suffixes The hint messages added for synthetic/anonymous class violations could break existing frozen violation stores, since FuzzyViolationLineMatcher would treat violations with hints as different from violations without hints. This change makes FuzzyViolationLineMatcher ignore hint suffixes (text following line separator + line separator + "Hint:") when comparing violations, similar to how it already ignores line numbers and anonymous class numbers. Resolves: #1509 Signed-off-by: chadongmin --- .../freeze/ViolationLineMatcherFactory.java | 15 ++++++-- .../ViolationLineMatcherFactoryTest.java | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/archunit/src/main/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactory.java b/archunit/src/main/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactory.java index 62203e40c1..ddac09a583 100644 --- a/archunit/src/main/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactory.java +++ b/archunit/src/main/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactory.java @@ -43,10 +43,14 @@ private static ViolationLineMatcher createInstance(String lineMatcherClassName) } /** - * ignores numbers that are potentially line numbers (digits following a ':' and preceding a ')') + * Ignores numbers that are potentially line numbers (digits following a ':' and preceding a ')') * or compiler-generated numbers of anonymous classes or lambda expressions (digits following a '$'). + * Also ignores hint suffixes (text following line separator + line separator + "Hint:") that provide + * additional guidance but are not part of the core violation message. */ private static class FuzzyViolationLineMatcher implements ViolationLineMatcher { + private static final String HINT_MARKER = System.lineSeparator() + System.lineSeparator() + "Hint:"; + @Override public boolean matches(String str1, String str2) { // Compare relevant substrings, in a more performant way than a regex solution like this: @@ -54,8 +58,8 @@ public boolean matches(String str1, String str2) { // normalize = str -> str.replaceAll(":\\d+\\)", ":0)").replaceAll("\\$\\d+", "\\$0"); // return normalize.apply(str1).equals(normalize.apply(str2)); - RelevantPartIterator relevantPart1 = new RelevantPartIterator(str1); - RelevantPartIterator relevantPart2 = new RelevantPartIterator(str2); + RelevantPartIterator relevantPart1 = new RelevantPartIterator(removeHintSuffix(str1)); + RelevantPartIterator relevantPart2 = new RelevantPartIterator(removeHintSuffix(str2)); while (relevantPart1.hasNext() && relevantPart2.hasNext()) { if (!relevantPart1.next().equals(relevantPart2.next())) { return false; @@ -64,6 +68,11 @@ public boolean matches(String str1, String str2) { return !relevantPart1.hasNext() && !relevantPart2.hasNext(); } + private static String removeHintSuffix(String str) { + int hintIndex = str.indexOf(HINT_MARKER); + return hintIndex >= 0 ? str.substring(0, hintIndex) : str; + } + static class RelevantPartIterator { private final String str; private final int length; diff --git a/archunit/src/test/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactoryTest.java b/archunit/src/test/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactoryTest.java index 6f42db0565..0d56c56885 100644 --- a/archunit/src/test/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactoryTest.java +++ b/archunit/src/test/java/com/tngtech/archunit/library/freeze/ViolationLineMatcherFactoryTest.java @@ -54,4 +54,39 @@ public void default_matcher(String str1, String str2, boolean expected) { .as(String.format("'%s' matches '%s'", str1, str2)) .isEqualTo(expected); } + + @Test + public void default_matcher_ignores_hint_suffix() { + ViolationLineMatcher defaultMatcher = ViolationLineMatcherFactory.create(); + + String baseViolation = "Class does not have simple name ending with 'Service' in (MyClass.java:42)"; + String hintSuffix = System.lineSeparator() + System.lineSeparator() + + "Hint: The failing class appears to be a synthetic or anonymous class " + + "generated by the compiler (e.g., from lambdas, switch expressions, or inner classes)."; + String violationWithHint = baseViolation + hintSuffix; + + assertThat(defaultMatcher.matches(baseViolation, violationWithHint)) + .as("violation without hint should match same violation with hint") + .isTrue(); + assertThat(defaultMatcher.matches(violationWithHint, baseViolation)) + .as("violation with hint should match same violation without hint") + .isTrue(); + assertThat(defaultMatcher.matches(violationWithHint, violationWithHint)) + .as("violation with hint should match itself") + .isTrue(); + } + + @Test + public void default_matcher_ignores_hint_suffix_with_different_line_numbers() { + ViolationLineMatcher defaultMatcher = ViolationLineMatcherFactory.create(); + + String violation1 = "Class does not have simple name ending with 'Service' in (MyClass.java:42)"; + String violation2WithHint = "Class does not have simple name ending with 'Service' in (MyClass.java:100)" + + System.lineSeparator() + System.lineSeparator() + + "Hint: The failing class appears to be a synthetic or anonymous class."; + + assertThat(defaultMatcher.matches(violation1, violation2WithHint)) + .as("violations with different line numbers and anonymous class numbers should match even with hint") + .isTrue(); + } }