From c2b4c5b649028131ea855f0819a9e1ed67f5d865 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:30:32 +0000 Subject: [PATCH 1/7] feat: detect build method by signature in builder contract check --- .../analysis/BuilderScopeResolver.java | 23 ++--- .../processor/analysis/JavaLangAnalyser.java | 28 +++++- .../processor/BuilderScopeResolverTest.java | 94 ++++++++++++++++++- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 16aa198b..9ed8bf0d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -111,11 +111,12 @@ public BuilderScopeResolver(ProcessingContext context) { * the classpath and returned if it satisfies the builder contract: an instantiation path * for an empty builder (no-arg constructor or static factory like {@code create()}), an * instantiation path seeded with the value (constructor accepting the referenced type or - * static factory like {@code create(T)}/{@code of(T)}), and a no-arg {@code build()} method - * returning it - each accessible from the generated builder's package. The contract check - * is annotation-agnostic, so builders generated with custom template annotations, external - * tools, or different suffixes are supported. The referenced type must not be opted out - * with {@code @Ignore4BuilderGeneration}. + * static factory like {@code create(T)}/{@code of(T)}), and an accessible, parameterless + * method returning it (the build method, regardless of name) - each accessible from the + * generated builder's package. The contract check is annotation-agnostic, so builders + * generated with custom template annotations, external tools, or different suffixes are + * supported. The referenced type must not be opted out with + * {@code @Ignore4BuilderGeneration}. * * * @param referencedType the type element being referenced as a field or collection element @@ -248,12 +249,12 @@ private Optional resolve(TypeElement referencedType) { * Looks up the candidate builder type on the classpath and verifies it satisfies the builder * contract: a way to create an empty instance (a no-arg constructor or a static parameterless * factory like {@code create()}), a way to create an instance seeded with a value (a constructor - * accepting the referenced type or a static factory like {@code create(T)}/{@code of(T)}), and a - * no-arg {@code build()} method returning it - each accessible from the generated builder's - * package, since the generated code calls them from there. The contract check is - * annotation-agnostic, so builders generated with custom template annotations or from external - * sources are supported as long as they follow the builder contract. It also avoids false - * positives like {@code String} → {@code StringBuilder}. + * accepting the referenced type or a static factory like {@code create(T)}/{@code of(T)}), and an + * accessible, parameterless method returning it (the build method, regardless of name) - each + * accessible from the generated builder's package, since the generated code calls them from + * there. The contract check is annotation-agnostic, so builders generated with custom template + * annotations or from external sources are supported as long as they follow the builder contract. + * It also avoids false positives like {@code String} → {@code StringBuilder}. * * @param candidate the candidate builder type name to look up * @param expectedType the referenced type the builder must accept and return diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 2d6d9fb8..d7345cb5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -244,21 +244,39 @@ public static Optional findAnnotationAttribute( } /** - * Checks whether the given type has a no-arg {@code build()} method returning the expected type. - * This is part of the builder contract used when the built value is retrieved. + * Checks whether the given type has a build method: a public, non-static, no-arg method returning + * the expected type, regardless of its name. This is part of the builder contract used when the + * built value is retrieved. * *

The check is annotation-agnostic and avoids false positives like {@code StringBuilder} for * {@code String}. * * @param builderType the candidate builder type element to check - * @param expectedReturnType the type that {@code build()} must return + * @param expectedReturnType the type that the build method must return * @param context the processing context, used to access all members - * @return {@code true} if the type declares or inherits a matching {@code build()} method + * @return {@code true} if the type declares or inherits a matching build method */ public static boolean hasBuildMethodReturning( TypeElement builderType, TypeName expectedReturnType, ProcessingContext context) { return findMethods(builderType, List.of(), expectedReturnType, context).stream() - .anyMatch(method -> method.getSimpleName().contentEquals("build")); + .anyMatch(method -> isNotStatic(method) && !isObjectMethod(method, context)); + } + + /** + * Checks whether the method's signature matches a method declared on {@link java.lang.Object} + * (e.g. {@code toString()}), which must not count as a build method. This avoids false positives + * like {@code StringBuilder#toString} satisfying the builder contract for {@code String}. + */ + private static boolean isObjectMethod(ExecutableElement method, ProcessingContext context) { + TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); + if (objectElement == null) { + return false; + } + return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() + .anyMatch( + objectMethod -> + objectMethod.getSimpleName().equals(method.getSimpleName()) + && objectMethod.getParameters().size() == method.getParameters().size()); } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index a6664bd9..c54033a6 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -740,7 +740,6 @@ private static Compilation compileWithBuilder(String builderSource) { return compileWithSources(LIB_HELPER, builderSource); } - private static Supplier> probe( Supplier> field) { return field; @@ -756,6 +755,92 @@ private static void assertFactoryMethod( } } + @Test + void resolverUsageScope_ResolvesBuilderWhenReturningMethodNotNamedBuild() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper toTarget() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // The build method is detected by signature, not by name: a public, parameterless method + // returning the referenced type satisfies the contract whatever it is called + assertEquals( + "lib.LibHelperBuilder", + ResolverProbeProcessor.contractResult.get().typeName().getFullQualifiedName()); + } + + @Test + void resolverUsageScope_RejectsBuilderWithoutParameterlessMethodReturningTarget() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public void build() {} + public LibHelper build(String name) { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // No parameterless method returns the referenced type, so the contract is not satisfied + assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); + } + + @Test + void resolverUsageScope_RejectsStaticOrNonPublicBuildCandidates() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public static LibHelper build() { return new LibHelper(); } + private LibHelper toTarget() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // Static factories and non-public methods do not count as the build method + assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); + } + private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; @@ -770,6 +855,7 @@ private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional usagePackagePrivate; private static Optional usageWithExistingDisabled; private static Optional usageGeneratedWithExistingDisabled; + private static Optional contractResult; private boolean captured; @@ -787,6 +873,7 @@ static void reset() { usagePackagePrivate = null; usageWithExistingDisabled = null; usageGeneratedWithExistingDisabled = null; + contractResult = null; } @Override @@ -857,6 +944,11 @@ public boolean process(Set annotations, RoundEnvironment resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageGeneratedWithExistingDisabled = resolver.resolveUsableBuilderType(helper); + // Builder-contract probe: resolved by classpath lookup only when the builder satisfies + // the contract (instantiation paths + accessible parameterless method returning it) + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + resolver.resetGeneratedBuilders(); + contractResult = resolver.resolveUsableBuilderType(helper); captured = true; return false; } From 360703986a1ce07617eef581e6ec55431e3e58e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:05:39 +0000 Subject: [PATCH 2/7] feat: record selected build method name in ResolvedBuilder Generated code emits a literal call to the builder's terminal method, so the contract resolution must remember which method matched. When several parameterless methods return the referenced type, 'build' is preferred and the alphabetically first name is chosen otherwise - matching the create()/of() preference used for static factories. Co-Authored-By: Andreas Igel --- .../analysis/BuilderScopeResolver.java | 19 +++++-- .../processor/analysis/JavaLangAnalyser.java | 17 ++++-- .../generators/util/MethodGeneratorUtil.java | 8 ++- .../processor/model/type/ResolvedBuilder.java | 22 ++++++- .../processor/BuilderScopeResolverTest.java | 57 +++++++++++++++++++ 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 9ed8bf0d..9280f7d2 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -271,13 +271,19 @@ private Optional resolveByBuilderContract( resolveFuncForEmptyBuilder(builderTypeElement); Optional funcForPrefilledBuilder = resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); + Optional buildMethod = + JavaLangAnalyser.findBuildMethod(builderTypeElement, expectedType, context); if (funcForEmptyBuilder.isEmpty() || funcForPrefilledBuilder.isEmpty() - || !JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { + || buildMethod.isEmpty()) { return Optional.empty(); } return Optional.of( - new ResolvedBuilder(candidate, funcForEmptyBuilder.get(), funcForPrefilledBuilder.get())); + new ResolvedBuilder( + candidate, + funcForEmptyBuilder.get(), + funcForPrefilledBuilder.get(), + buildMethod.get().getSimpleName().toString())); } /** @@ -398,9 +404,9 @@ private Optional resolveAnchoredBuilder( for (ExecutableElement factory : factories) { TypeElement builderElement = (TypeElement) ((DeclaredType) factory.getReturnType()).asElement(); - if (!context.isMemberAccessibleFromBuilderPackage(builderElement) - || !JavaLangAnalyser.hasBuildMethodReturning( - builderElement, referencedTypeName, context)) { + Optional buildMethod = + JavaLangAnalyser.findBuildMethod(builderElement, referencedTypeName, context); + if (!context.isMemberAccessibleFromBuilderPackage(builderElement) || buildMethod.isEmpty()) { continue; } Optional funcForPrefilledBuilder = @@ -411,7 +417,8 @@ private Optional resolveAnchoredBuilder( JavaLangMapper.mapToTypeName(builderElement, context), new BuilderInstantiation.AnchorFactoryCall( referencedTypeName, factory.getSimpleName().toString()), - funcForPrefilledBuilder.get())); + funcForPrefilledBuilder.get(), + buildMethod.get().getSimpleName().toString())); } } return Optional.empty(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index d7345cb5..b680dbf0 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -29,6 +29,7 @@ import static javax.lang.model.type.TypeKind.VOID; import java.lang.annotation.Annotation; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -244,9 +245,10 @@ public static Optional findAnnotationAttribute( } /** - * Checks whether the given type has a build method: a public, non-static, no-arg method returning + * Finds the build method on the given type: an accessible, non-static, no-arg method returning * the expected type, regardless of its name. This is part of the builder contract used when the - * built value is retrieved. + * built value is retrieved. When several candidates exist, {@code build} is preferred, then the + * alphabetically first name. * *

The check is annotation-agnostic and avoids false positives like {@code StringBuilder} for * {@code String}. @@ -254,12 +256,17 @@ public static Optional findAnnotationAttribute( * @param builderType the candidate builder type element to check * @param expectedReturnType the type that the build method must return * @param context the processing context, used to access all members - * @return {@code true} if the type declares or inherits a matching build method + * @return the selected build method, or empty if the type declares or inherits no matching method */ - public static boolean hasBuildMethodReturning( + public static Optional findBuildMethod( TypeElement builderType, TypeName expectedReturnType, ProcessingContext context) { return findMethods(builderType, List.of(), expectedReturnType, context).stream() - .anyMatch(method -> isNotStatic(method) && !isObjectMethod(method, context)); + .filter(method -> isNotStatic(method) && !isObjectMethod(method, context)) + .min( + Comparator.comparingInt( + (ExecutableElement method) -> + "build".contentEquals(method.getSimpleName()) ? 0 : 1) + .thenComparing(method -> method.getSimpleName().toString())); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java index 6fb7a55f..ba4538d7 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java @@ -232,7 +232,8 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( createBuilderMethod(field.getOriginalFieldName(), parentBuilderType, context); methodDto.addParameter(parameter); - String buildExpression = calculateBuildExpression(field.getFieldType()); + String buildExpression = + calculateBuildExpression(field.getFieldType(), fieldBuilder.buildMethodName()); methodDto.setCode( """ @@ -285,10 +286,11 @@ public static TypeNameGeneric createConsumerType(TypeName builderType) { * Calculates the build expression for a field type, wrapping concrete collections if needed. * * @param fieldType the field type + * @param buildMethodName the name of the builder method returning the built value * @return the build expression */ - private static String calculateBuildExpression(TypeName fieldType) { - return wrapConcreteCollectionType(fieldType, "builder.build()"); + private static String calculateBuildExpression(TypeName fieldType, String buildMethodName) { + return wrapConcreteCollectionType(fieldType, "builder." + buildMethodName + "()"); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java index b96fdeda..33d74a06 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java @@ -27,14 +27,17 @@ * {@code B.create()}) * @param funcForPrefilledBuilder how to obtain a builder instance seeded with a value (e.g. {@code * new B(value)} or {@code B.of(value)}) + * @param buildMethodName the name of the parameterless method returning the built value (e.g. + * {@code build}) */ public record ResolvedBuilder( TypeName typeName, BuilderInstantiation funcForEmptyBuilder, - BuilderInstantiation funcForPrefilledBuilder) { + BuilderInstantiation funcForPrefilledBuilder, + String buildMethodName) { /** - * A resolution instantiated exclusively through constructors. + * A resolution instantiated exclusively through constructors, building with {@code build()}. * * @param typeName the builder type to reference */ @@ -44,4 +47,19 @@ public ResolvedBuilder(TypeName typeName) { new BuilderInstantiation.ConstructorCall(), new BuilderInstantiation.ConstructorCall()); } + + /** + * A resolution whose terminal build method is named {@code build}, as on builders generated by + * this processor. + * + * @param typeName the builder type to reference + * @param funcForEmptyBuilder how to obtain an empty builder instance + * @param funcForPrefilledBuilder how to obtain a builder instance seeded with a value + */ + public ResolvedBuilder( + TypeName typeName, + BuilderInstantiation funcForEmptyBuilder, + BuilderInstantiation funcForPrefilledBuilder) { + this(typeName, funcForEmptyBuilder, funcForPrefilledBuilder, "build"); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index c54033a6..9088d20a 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -783,6 +783,63 @@ public LibHelperBuilder(LibHelper value) {} assertEquals( "lib.LibHelperBuilder", ResolverProbeProcessor.contractResult.get().typeName().getFullQualifiedName()); + assertEquals("toTarget", ResolverProbeProcessor.contractResult.get().buildMethodName()); + } + + @Test + void resolverUsageScope_PrefersBuildNamedMethodAmongCandidates() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper assemble() { return new LibHelper(); } + public LibHelper build() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // `build` wins over other signature-matching methods even if they sort earlier by name + assertEquals("build", ResolverProbeProcessor.contractResult.get().buildMethodName()); + } + + @Test + void resolverUsageScope_PicksAlphabeticallyFirstMethodWhenNoBuildExists() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper toTarget() { return new LibHelper(); } + public LibHelper assemble() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // Without a `build` method, the pick is deterministic: the alphabetically first name + assertEquals("assemble", ResolverProbeProcessor.contractResult.get().buildMethodName()); } @Test From 605c4b0ff81389af57ca19276d64a1bbbe0babe4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:57:51 +0000 Subject: [PATCH 3/7] refactor: move build-method selection into BuilderScopeResolver The contract-specific selection (build-name preference, non-static and Object-method filtering) belongs to the resolver's contract check; JavaLangAnalyser keeps only the generic findMethods lookup, which already restricts candidates to members accessible from the generated builder's package. Co-Authored-By: Andreas Igel --- .../analysis/BuilderScopeResolver.java | 40 +++++++++++++++-- .../processor/analysis/JavaLangAnalyser.java | 43 ------------------- 2 files changed, 36 insertions(+), 47 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 9280f7d2..322c4bf1 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -271,8 +271,7 @@ private Optional resolveByBuilderContract( resolveFuncForEmptyBuilder(builderTypeElement); Optional funcForPrefilledBuilder = resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); - Optional buildMethod = - JavaLangAnalyser.findBuildMethod(builderTypeElement, expectedType, context); + Optional buildMethod = findBuildMethod(builderTypeElement, expectedType); if (funcForEmptyBuilder.isEmpty() || funcForPrefilledBuilder.isEmpty() || buildMethod.isEmpty()) { @@ -404,8 +403,7 @@ private Optional resolveAnchoredBuilder( for (ExecutableElement factory : factories) { TypeElement builderElement = (TypeElement) ((DeclaredType) factory.getReturnType()).asElement(); - Optional buildMethod = - JavaLangAnalyser.findBuildMethod(builderElement, referencedTypeName, context); + Optional buildMethod = findBuildMethod(builderElement, referencedTypeName); if (!context.isMemberAccessibleFromBuilderPackage(builderElement) || buildMethod.isEmpty()) { continue; } @@ -499,6 +497,40 @@ private static int preferredFactoryNameRank(String name) { return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index; } + /** + * Finds the build method on the builder type: an accessible, non-static, parameterless method + * returning the referenced type, regardless of its name. When several candidates exist, {@code + * build} is preferred, then the alphabetically first name. Methods whose signature matches a + * {@link java.lang.Object} method (e.g. {@code toString()}) are excluded so types like {@code + * StringBuilder} do not satisfy the contract for {@code String}. + * + * @param builderType the candidate builder type to inspect + * @param expectedType the referenced type the build method must return + * @return the selected build method, or empty if no matching method exists + */ + private Optional findBuildMethod( + TypeElement builderType, TypeName expectedType) { + return JavaLangAnalyser.findMethods(builderType, List.of(), expectedType, context).stream() + .filter(method -> JavaLangAnalyser.isNotStatic(method) && !isObjectMethod(method)) + .min( + Comparator.comparingInt( + (ExecutableElement method) -> + "build".contentEquals(method.getSimpleName()) ? 0 : 1) + .thenComparing(method -> method.getSimpleName().toString())); + } + + private boolean isObjectMethod(ExecutableElement method) { + TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); + if (objectElement == null) { + return false; + } + return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() + .anyMatch( + objectMethod -> + objectMethod.getSimpleName().equals(method.getSimpleName()) + && objectMethod.getParameters().size() == method.getParameters().size()); + } + private void refreshForConfigurationIfNeeded() { BuilderConfiguration configuration = context.getConfiguration(); ResolutionInputs inputs = new ResolutionInputs(configuration, context.getBuilderPackageName()); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index b680dbf0..6323fad4 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -29,7 +29,6 @@ import static javax.lang.model.type.TypeKind.VOID; import java.lang.annotation.Annotation; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -244,48 +243,6 @@ public static Optional findAnnotationAttribute( return Optional.empty(); } - /** - * Finds the build method on the given type: an accessible, non-static, no-arg method returning - * the expected type, regardless of its name. This is part of the builder contract used when the - * built value is retrieved. When several candidates exist, {@code build} is preferred, then the - * alphabetically first name. - * - *

The check is annotation-agnostic and avoids false positives like {@code StringBuilder} for - * {@code String}. - * - * @param builderType the candidate builder type element to check - * @param expectedReturnType the type that the build method must return - * @param context the processing context, used to access all members - * @return the selected build method, or empty if the type declares or inherits no matching method - */ - public static Optional findBuildMethod( - TypeElement builderType, TypeName expectedReturnType, ProcessingContext context) { - return findMethods(builderType, List.of(), expectedReturnType, context).stream() - .filter(method -> isNotStatic(method) && !isObjectMethod(method, context)) - .min( - Comparator.comparingInt( - (ExecutableElement method) -> - "build".contentEquals(method.getSimpleName()) ? 0 : 1) - .thenComparing(method -> method.getSimpleName().toString())); - } - - /** - * Checks whether the method's signature matches a method declared on {@link java.lang.Object} - * (e.g. {@code toString()}), which must not count as a build method. This avoids false positives - * like {@code StringBuilder#toString} satisfying the builder contract for {@code String}. - */ - private static boolean isObjectMethod(ExecutableElement method, ProcessingContext context) { - TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); - if (objectElement == null) { - return false; - } - return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() - .anyMatch( - objectMethod -> - objectMethod.getSimpleName().equals(method.getSimpleName()) - && objectMethod.getParameters().size() == method.getParameters().size()); - } - /** * Single-parameter variant of {@link #findMethodsStatic(TypeElement, List, TypeName, * ProcessingContext)}. From 1032fa3233008312bcc76e6f89fb5700eb53889d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:08:47 +0000 Subject: [PATCH 4/7] refactor: render build call on ResolvedBuilder Like the instantiationCode/methodReference renderers on BuilderInstantiation, the record now renders its own terminal call via buildCall(builderExpression); MethodGeneratorUtil's private calculateBuildExpression helper is no longer needed. Co-Authored-By: Andreas Igel --- .../generators/util/MethodGeneratorUtil.java | 13 +------------ .../processor/model/type/ResolvedBuilder.java | 11 +++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java index ba4538d7..8c7b6bfd 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java @@ -233,7 +233,7 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( methodDto.addParameter(parameter); String buildExpression = - calculateBuildExpression(field.getFieldType(), fieldBuilder.buildMethodName()); + wrapConcreteCollectionType(field.getFieldType(), fieldBuilder.buildCall("builder")); methodDto.setCode( """ @@ -282,17 +282,6 @@ public static TypeNameGeneric createConsumerType(TypeName builderType) { return new TypeNameGeneric(JavaLangMapper.map2TypeName(Consumer.class), builderType); } - /** - * Calculates the build expression for a field type, wrapping concrete collections if needed. - * - * @param fieldType the field type - * @param buildMethodName the name of the builder method returning the built value - * @return the build expression - */ - private static String calculateBuildExpression(TypeName fieldType, String buildMethodName) { - return wrapConcreteCollectionType(fieldType, "builder." + buildMethodName + "()"); - } - /** * Wraps an expression with a concrete collection constructor if needed. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java index 33d74a06..ebac36e5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java @@ -62,4 +62,15 @@ public ResolvedBuilder( BuilderInstantiation funcForPrefilledBuilder) { this(typeName, funcForEmptyBuilder, funcForPrefilledBuilder, "build"); } + + /** + * Renders the terminal call producing the built value: {@code + * builderExpression.buildMethodName()}. + * + * @param builderExpression expression evaluating to the builder instance (e.g. {@code builder}) + * @return the build call code + */ + public String buildCall(String builderExpression) { + return builderExpression + "." + buildMethodName + "()"; + } } From 1daefbe64f17fa928b9aad7e049ba233f21a072b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:39:29 +0000 Subject: [PATCH 5/7] refactor: move Object-method check to JavaLangAnalyser Renamed to isMethodOfObjectClass alongside isNoMethodOfObjectClass: the existing check filters by declaring type and misses overrides like StringBuilder.toString, while the signature comparison catches them. Co-Authored-By: Andreas Igel --- .../analysis/BuilderScopeResolver.java | 17 ++++---------- .../processor/analysis/JavaLangAnalyser.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 322c4bf1..2f47b8c3 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -511,7 +511,10 @@ private static int preferredFactoryNameRank(String name) { private Optional findBuildMethod( TypeElement builderType, TypeName expectedType) { return JavaLangAnalyser.findMethods(builderType, List.of(), expectedType, context).stream() - .filter(method -> JavaLangAnalyser.isNotStatic(method) && !isObjectMethod(method)) + .filter( + method -> + JavaLangAnalyser.isNotStatic(method) + && !JavaLangAnalyser.isMethodOfObjectClass(method, context)) .min( Comparator.comparingInt( (ExecutableElement method) -> @@ -519,18 +522,6 @@ private Optional findBuildMethod( .thenComparing(method -> method.getSimpleName().toString())); } - private boolean isObjectMethod(ExecutableElement method) { - TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); - if (objectElement == null) { - return false; - } - return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() - .anyMatch( - objectMethod -> - objectMethod.getSimpleName().equals(method.getSimpleName()) - && objectMethod.getParameters().size() == method.getParameters().size()); - } - private void refreshForConfigurationIfNeeded() { BuilderConfiguration configuration = context.getConfiguration(); ResolutionInputs inputs = new ResolutionInputs(configuration, context.getBuilderPackageName()); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 6323fad4..51a93cc4 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -69,6 +69,28 @@ public static boolean isNoMethodOfObjectClass(ExecutableElement mth) { || Strings.CS.equals("Object", simpleNameOfParent)); } + /** + * Checks whether the method's signature matches a method declared on {@link java.lang.Object} + * (e.g. {@code toString()}). Unlike {@link #isNoMethodOfObjectClass}, which filters by declaring + * type, this compares the signature and therefore also matches overrides like {@code + * StringBuilder#toString}. + * + * @param method ExecutableElement to be validated + * @param context the processing context, used to resolve {@link java.lang.Object}'s methods + * @return {@code true}, if the method has the signature of an {@link java.lang.Object} method + */ + public static boolean isMethodOfObjectClass(ExecutableElement method, ProcessingContext context) { + TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); + if (objectElement == null) { + return false; + } + return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() + .anyMatch( + objectMethod -> + objectMethod.getSimpleName().equals(method.getSimpleName()) + && objectMethod.getParameters().size() == method.getParameters().size()); + } + /** * Gets all methods of a class, including inherited methods, excluding methods from {@code * java.lang.Object}. From 5ff8934ccfee52d3d7bdb8f0ae6b3166218569ec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:43:10 +0000 Subject: [PATCH 6/7] refactor: single signature-based isNoMethodOfObjectClass + contract coverage The existing check filtered by declaring type and missed overrides like StringBuilder.toString; now signature-based and shared between setter detection and the contract's build-method selection. New tests: StringBuilder rejected for String (Object-signature guard), inherited build method selected, package-private build method rejected when inaccessible. Co-Authored-By: Andreas Igel --- .../analysis/BuilderScopeResolver.java | 2 +- .../processor/analysis/JavaLangAnalyser.java | 32 ++----- .../processor/BuilderScopeResolverTest.java | 89 +++++++++++++++++++ 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 2f47b8c3..f39ccd73 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -514,7 +514,7 @@ private Optional findBuildMethod( .filter( method -> JavaLangAnalyser.isNotStatic(method) - && !JavaLangAnalyser.isMethodOfObjectClass(method, context)) + && JavaLangAnalyser.isNoMethodOfObjectClass(method, context)) .min( Comparator.comparingInt( (ExecutableElement method) -> diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 51a93cc4..a7f8caa8 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -58,37 +58,23 @@ public final class JavaLangAnalyser { private JavaLangAnalyser() {} /** - * Helper function to filter methods from {@code java.lang.Object}. + * Helper function to filter methods from {@code java.lang.Object}. The comparison is + * signature-based, so overrides like {@code StringBuilder#toString} count as Object methods too. * * @param mth ExecutableElement to be validated - * @return {@code true}, if it is no method from java.lang.Object - */ - public static boolean isNoMethodOfObjectClass(ExecutableElement mth) { - String simpleNameOfParent = mth.getEnclosingElement().getSimpleName().toString(); - return !(Strings.CS.equals("java.lang.Object", simpleNameOfParent) - || Strings.CS.equals("Object", simpleNameOfParent)); - } - - /** - * Checks whether the method's signature matches a method declared on {@link java.lang.Object} - * (e.g. {@code toString()}). Unlike {@link #isNoMethodOfObjectClass}, which filters by declaring - * type, this compares the signature and therefore also matches overrides like {@code - * StringBuilder#toString}. - * - * @param method ExecutableElement to be validated * @param context the processing context, used to resolve {@link java.lang.Object}'s methods - * @return {@code true}, if the method has the signature of an {@link java.lang.Object} method + * @return {@code true}, if it is no method with the signature of a java.lang.Object method */ - public static boolean isMethodOfObjectClass(ExecutableElement method, ProcessingContext context) { + public static boolean isNoMethodOfObjectClass(ExecutableElement mth, ProcessingContext context) { TypeElement objectElement = context.getTypeElement(Object.class.getCanonicalName()); if (objectElement == null) { - return false; + return true; } return ElementFilter.methodsIn(objectElement.getEnclosedElements()).stream() - .anyMatch( + .noneMatch( objectMethod -> - objectMethod.getSimpleName().equals(method.getSimpleName()) - && objectMethod.getParameters().size() == method.getParameters().size()); + objectMethod.getSimpleName().equals(mth.getSimpleName()) + && objectMethod.getParameters().size() == mth.getParameters().size()); } /** @@ -102,7 +88,7 @@ public static boolean isMethodOfObjectClass(ExecutableElement method, Processing public static List findAllPossibleSettersOfClass( TypeElement typeElement, ProcessingContext context) { return ElementFilter.methodsIn(context.getAllMembers(typeElement)).stream() - .filter(JavaLangAnalyser::isNoMethodOfObjectClass) + .filter(mth -> isNoMethodOfObjectClass(mth, context)) .filter(JavaLangAnalyser::isSetterForField) .sorted( (m1, m2) -> { diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 9088d20a..cf6e5ebe 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -842,6 +842,87 @@ public LibHelperBuilder(LibHelper value) {} assertEquals("assemble", ResolverProbeProcessor.contractResult.get().buildMethodName()); } + @Test + void resolverUsageScope_ResolvesBuilderWithInheritedBuildMethod() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBase { + public LibHelper toTarget() { return new LibHelper(); } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder extends LibHelperBase { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + } + """)); + + assertThat(compilation).succeeded(); + // Inherited members count for the contract: the build method declared on the superclass is + // selected and its name recorded + assertEquals("toTarget", ResolverProbeProcessor.contractResult.get().buildMethodName()); + } + + @Test + void resolverUsageScope_RejectsPackagePrivateBuildMethodForOtherPackageBuilder() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + LibHelper toTarget() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // The probe generates builders in the default package, so a package-private build method in + // lib is not accessible and the contract fails + assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); + } + + @Test + void resolverUsageScope_RejectsStringBuilderForString() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { public LibHelper() {} } + """)); + + assertThat(compilation).succeeded(); + // StringBuilder satisfies the instantiation contract for String, but its only parameterless + // method returning String is toString() - an Object signature, not a build method + assertEquals(Optional.empty(), ResolverProbeProcessor.contractResultString); + } + @Test void resolverUsageScope_RejectsBuilderWithoutParameterlessMethodReturningTarget() { ResolverProbeProcessor.reset(); @@ -913,6 +994,7 @@ private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional usageWithExistingDisabled; private static Optional usageGeneratedWithExistingDisabled; private static Optional contractResult; + private static Optional contractResultString; private boolean captured; @@ -931,6 +1013,7 @@ static void reset() { usageWithExistingDisabled = null; usageGeneratedWithExistingDisabled = null; contractResult = null; + contractResultString = null; } @Override @@ -1006,6 +1089,12 @@ public boolean process(Set annotations, RoundEnvironment context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); resolver.resetGeneratedBuilders(); contractResult = resolver.resolveUsableBuilderType(helper); + // Same probe for java.lang.String: StringBuilder must not resolve because its only + // parameterless method returning String is toString(), an Object signature + TypeElement stringElement = + processingEnv.getElementUtils().getTypeElement("java.lang.String"); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("java.lang"), "")); + contractResultString = resolver.resolveUsableBuilderType(stringElement); captured = true; return false; } From 8f16724e99d48f2a3e20ad157174d302be37498f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:03:47 +0000 Subject: [PATCH 7/7] test: parameterize contract-rejection tests, reuse existing probes Fold the new rejection cases into rejectedContractVariants and point the resolve-side tests at the equivalent usageWithoutAnnotation probe instead of a duplicated contractResult field (Sonar java:S5976). Co-Authored-By: Andreas Igel --- .../processor/BuilderScopeResolverTest.java | 147 ++++++------------ 1 file changed, 49 insertions(+), 98 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index cf6e5ebe..2e2bbf70 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -620,6 +620,47 @@ public class LibHelperBuilder { LibHelper build() { return new LibHelper(); } } """, + probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)), + // Neither void build() nor build(String) is a parameterless method returning the + // referenced type + Arguments.argumentSet( + "NoParameterlessMethodReturningTarget", + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public void build() {} + public LibHelper build(String name) { return new LibHelper(); } + } + """, + probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)), + // A static build() is a factory, not the terminal method; a private one is not + // accessible + Arguments.argumentSet( + "StaticOrPrivateBuildMethods", + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public static LibHelper build() { return new LibHelper(); } + private LibHelper toTarget() { return new LibHelper(); } + } + """, + probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)), + // The terminal method is package-private: not accessible from the generated builder's + // package (builderPackage unset), even though the constructors are public + Arguments.argumentSet( + "PackagePrivateBuildMethod_OtherPackage", + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + LibHelper toTarget() { return new LibHelper(); } + } + """, probe(() -> ResolverProbeProcessor.usageWithoutAnnotation))); } @@ -782,8 +823,8 @@ public LibHelperBuilder(LibHelper value) {} // returning the referenced type satisfies the contract whatever it is called assertEquals( "lib.LibHelperBuilder", - ResolverProbeProcessor.contractResult.get().typeName().getFullQualifiedName()); - assertEquals("toTarget", ResolverProbeProcessor.contractResult.get().buildMethodName()); + ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName()); + assertEquals("toTarget", ResolverProbeProcessor.usageWithoutAnnotation.get().buildMethodName()); } @Test @@ -811,7 +852,7 @@ public LibHelperBuilder(LibHelper value) {} assertThat(compilation).succeeded(); // `build` wins over other signature-matching methods even if they sort earlier by name - assertEquals("build", ResolverProbeProcessor.contractResult.get().buildMethodName()); + assertEquals("build", ResolverProbeProcessor.usageWithoutAnnotation.get().buildMethodName()); } @Test @@ -839,7 +880,7 @@ public LibHelperBuilder(LibHelper value) {} assertThat(compilation).succeeded(); // Without a `build` method, the pick is deterministic: the alphabetically first name - assertEquals("assemble", ResolverProbeProcessor.contractResult.get().buildMethodName()); + assertEquals("assemble", ResolverProbeProcessor.usageWithoutAnnotation.get().buildMethodName()); } @Test @@ -873,35 +914,7 @@ public LibHelperBuilder(LibHelper value) {} assertThat(compilation).succeeded(); // Inherited members count for the contract: the build method declared on the superclass is // selected and its name recorded - assertEquals("toTarget", ResolverProbeProcessor.contractResult.get().buildMethodName()); - } - - @Test - void resolverUsageScope_RejectsPackagePrivateBuildMethodForOtherPackageBuilder() { - ResolverProbeProcessor.reset(); - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - public LibHelperBuilder() {} - public LibHelperBuilder(LibHelper value) {} - LibHelper toTarget() { return new LibHelper(); } - } - """)); - - assertThat(compilation).succeeded(); - // The probe generates builders in the default package, so a package-private build method in - // lib is not accessible and the contract fails - assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); + assertEquals("toTarget", ResolverProbeProcessor.usageWithoutAnnotation.get().buildMethodName()); } @Test @@ -923,62 +936,6 @@ public class LibHelper { public LibHelper() {} } assertEquals(Optional.empty(), ResolverProbeProcessor.contractResultString); } - @Test - void resolverUsageScope_RejectsBuilderWithoutParameterlessMethodReturningTarget() { - ResolverProbeProcessor.reset(); - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - public LibHelperBuilder() {} - public LibHelperBuilder(LibHelper value) {} - public void build() {} - public LibHelper build(String name) { return new LibHelper(); } - } - """)); - - assertThat(compilation).succeeded(); - // No parameterless method returns the referenced type, so the contract is not satisfied - assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); - } - - @Test - void resolverUsageScope_RejectsStaticOrNonPublicBuildCandidates() { - ResolverProbeProcessor.reset(); - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - public LibHelperBuilder() {} - public LibHelperBuilder(LibHelper value) {} - public static LibHelper build() { return new LibHelper(); } - private LibHelper toTarget() { return new LibHelper(); } - } - """)); - - assertThat(compilation).succeeded(); - // Static factories and non-public methods do not count as the build method - assertEquals(Optional.empty(), ResolverProbeProcessor.contractResult); - } - private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; @@ -993,7 +950,6 @@ private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional usagePackagePrivate; private static Optional usageWithExistingDisabled; private static Optional usageGeneratedWithExistingDisabled; - private static Optional contractResult; private static Optional contractResultString; private boolean captured; @@ -1012,7 +968,6 @@ static void reset() { usagePackagePrivate = null; usageWithExistingDisabled = null; usageGeneratedWithExistingDisabled = null; - contractResult = null; contractResultString = null; } @@ -1084,16 +1039,12 @@ public boolean process(Set annotations, RoundEnvironment resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageGeneratedWithExistingDisabled = resolver.resolveUsableBuilderType(helper); - // Builder-contract probe: resolved by classpath lookup only when the builder satisfies - // the contract (instantiation paths + accessible parameterless method returning it) - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + // Builder-contract probe for java.lang.String: StringBuilder must not resolve because its + // only parameterless method returning String is toString(), an Object signature + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("java.lang"), "")); resolver.resetGeneratedBuilders(); - contractResult = resolver.resolveUsableBuilderType(helper); - // Same probe for java.lang.String: StringBuilder must not resolve because its only - // parameterless method returning String is toString(), an Object signature TypeElement stringElement = processingEnv.getElementUtils().getTypeElement("java.lang.String"); - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("java.lang"), "")); contractResultString = resolver.resolveUsableBuilderType(stringElement); captured = true; return false;