diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 68a39142..858f157b 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -745,8 +745,8 @@ case.
The processor constructs the candidate builder name using `builderUsageSuffix`
(or `builderSuffix` if not configured) and verifies the builder contract: a
-constructor accepting the referenced type and a no-arg `build()` method returning
-it. Any class with the expected name and a matching contract qualifies, allowing
+constructor accepting the referenced type, a no-arg constructor, and a no-arg
+`build()` method returning it. Any class with the expected name and a matching contract qualifies, allowing
references to builders generated with custom template annotations, external tools,
or different suffixes. If the candidate builder cannot be found, the field falls
back to a plain setter.
@@ -1023,8 +1023,8 @@ If empty, `builderSuffix` is used instead. This allows referencing builders that
with a different suffix (e.g. by another module using `"Factory"` as suffix) without changing
the suffix used for own builder generation.
-The candidate class must provide a constructor accepting the referenced type and a no-arg
-`build()` method returning it. The contract check is annotation-agnostic, so builders
+The candidate class must provide a constructor accepting the referenced type, a no-arg
+constructor, and a no-arg `build()` method returning it. The contract check is annotation-agnostic, so builders
generated with custom template annotations or external tools are supported. If the
candidate class does not exist or does not satisfy this contract, the field falls back
to a plain setter.
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 b2c96064..f8efc32c 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
@@ -86,10 +86,10 @@ public BuilderScopeResolver(ProcessingContext context) {
*
Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix}
* (falling back to {@code builderSuffix} if not configured). The candidate is looked up on
* the classpath and returned if it satisfies the builder contract: a constructor accepting
- * the referenced type and a no-arg {@code build()} method returning it. 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}.
+ * the referenced type, a no-arg constructor, and a no-arg {@code build()} method returning
+ * it. 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
@@ -199,10 +199,11 @@ private Optional resolve(TypeElement referencedType) {
/**
* Looks up the candidate builder type on the classpath and verifies it satisfies the builder
- * contract: a constructor accepting the referenced type and a no-arg {@code build()} method
- * returning it. 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}.
+ * contract: a constructor accepting the referenced type, a no-arg constructor, and a no-arg
+ * {@code build()} method returning it. 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 qualified name of the referenced type the builder must accept and
@@ -217,6 +218,9 @@ private Optional resolveByBuilderContract(TypeName candidate, String e
if (!JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) {
return Optional.empty();
}
+ if (!JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) {
+ return Optional.empty();
+ }
if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) {
return Optional.empty();
}
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 f82c557f..9d24179c 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
@@ -156,6 +156,33 @@ public LibHelperBuilder(LibHelper value) {}
ResolverProbeProcessor.usageWithoutAnnotation.get().getFullQualifiedName());
}
+ @Test
+ void resolverUsageScope_RejectsBuilderWithoutNoArgConstructor() {
+ 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(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // ctor(T) + build() but no no-arg ctor: generated consumer code calls `new
+ // LibHelperBuilder()`, so the builder must not qualify
+ assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation);
+ }
+
@Test
void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() {
ResolverProbeProcessor.reset();