Skip to content

Scope builder generation and builder usage by package (#114) - #288

Merged
AndreasIgel merged 57 commits into
java-helpers:mainfrom
AndreasIgel:devin/builder-scope
Sep 19, 2026
Merged

AndreasIgel merged 57 commits into
java-helpers:mainfrom
AndreasIgel:devin/builder-scope

Conversation

@AndreasIgel

@AndreasIgel AndreasIgel commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Previously the processor emitted a builder reference for every @SimpleBuilder-annotated field/collection-element type. That breaks when the referenced type is a precompiled library DTO whose builder was never generated: the generated code references a class that does not exist.

Two comma-separated package-list options now scope this, and the "may a builder be used here?" decision is centralized in one new class so generators/enhancers stay ignorant of the mechanism — they keep checking only TypeName.getBuilderType().isPresent().

  • builderGenerationPackages — when non-empty, BuilderProcessor only generates builders for annotated DTOs in scope. Generation-scope packages are automatically included in the usage scope.
  • builderUsagePackages — packages whose builders may be used as helpers. The candidate builder must be verified on the classpath before a reference is emitted; unresolvable ⇒ the field falls back to a plain setter.
  • builderUsageSuffix — suffix used when looking up usage builders on the classpath (defaults to builderSuffix when empty). Allows referencing builders generated with a different suffix, e.g. LibraryDtoFactory instead of LibraryDtoBuilder.
  • Both scopes empty/unset ⇒ behavior is exactly as before (reference any annotated type, no type search).

Builder contract instead of annotation check

Classpath lookup does not check for @SimpleBuilder (or any other annotation). Instead, the candidate class must satisfy the builder contract:

  1. A constructor accepting the referenced type (used when the field already has a value)
  2. A no-arg build() method returning the referenced type (used to obtain the built value)

This is annotation-agnostic, so builders generated with custom @SimpleBuilder.Template annotations or external tools are supported. It also avoids false positives like StringStringBuilder.

Decision logic

All in BuilderScopeResolver (analysis/), created once in the ProcessingContext constructor (getBuilderScopeResolver()); it reads the current target configuration on use, re-parses its package scopes only when that configuration changes, and caches the per-type result (Optional<TypeName> by FQN) so each referenced type is analysed once per configuration:

Optional<TypeName> resolveUsableBuilderType(TypeElement referenced) {
  if (referenced == null || hasIgnore4BuilderGeneration(referenced)) return empty(); // opt-out wins
  if (usageScope.isScoped() && !usageScope.includes(pkg))            return empty(); // scope gate
  if (generatedTypeNames.contains(fqn))                              return of(candidate); // trusted, no contract check
  return resolveByBuilderContract(candidate, fqn);                                    // classpath verify
}

The effective usage scope merges builderGenerationPackages and builderUsagePackages via PackageScopes.merge(...), so generation-scope packages never need to be repeated in the usage scope.

generatedTypeNames covers builders being generated in the current processing round — they are trusted without a classpath lookup because our own generators always satisfy the contract. BuilderProcessor resolves configuration and applies the generation filter for all elements first, registers the survivors via BuilderScopeResolver.registerGeneratedTypes(...), and only then generates. Types in neither scope stay unreferenced even when generated in the same round.

Opt-out precedence is structural: @Ignore4BuilderGeneration is checked first in the resolver, so an opted-out type can never be referenced, even when its package is in scope. The previous check in JavaLangMapper was folded into the resolver so there is a single decision point; the mapper now only does resolveUsableBuilderType(...).ifPresent(typeName::setBuilderType) (and the collection-element equivalent).

Package matching is case-insensitive and boundary-aware — Strings.CI.equals(pkg, p) || Strings.CI.startsWith(pkg, p + ".") (so a.bc does not match scope a.b, while COM.Example matches com.example) — implemented once in PackageScopes (model/core/) and shared by the resolver and BuilderProcessor. PackageScopes.merge combines two scopes into a union; BuilderConfiguration.overrideScopes (renamed from mergeScopes — it is an override, not a merge) resolves annotation-level values over compiler-argument-level ones.

Wiring is the usual three layers: SimpleBuilder.Options, CompilerArgumentsEnum / CompilerArgumentsReader (-Asimplebuilder.builderGenerationPackages, -Asimplebuilder.builderUsagePackages, -Asimplebuilder.builderUsageSuffix), BuilderConfigurationReader.parseOptionsFromMirror, and BuilderConfiguration (record field, DEFAULT, builder setter via trimToNull, merge, toString, getters).

Example module

A runnable demo lives in org.javahelpers.simple.builders.example.scoping, with the generated result committed under example/generated-example-builder/. ScopedOwnerDto configures both scopes inline and has three fields covering all three outcomes:

field type's package outcome in ScopedOwnerDtoBuilder
trusted generation scope trusted(Consumer<TrustedHelperDtoBuilder>) emitted
library usage scope, builder absent plain setter only — the bug this PR fixes
sponsor neither scope plain setter only, although SponsorDtoBuilder exists

LibraryHelperDto is annotated but configured outside the generation scope, so no builder is generated for it — that is how a precompiled library DTO without a builder is simulated inside one compilation.

Note: the example module's tests are compiled but never executed by Maven (the reactor inherits Surefire 2.12.4 and the module has no JUnit 5 engine), so ScopedOwnerDtoBuilderTest is compile-checked in CI and was run out-of-band via the JUnit Platform launcher (2/2 passing). This is pre-existing and affects the module's other test classes too.

Tests

BuilderScopeProcessingTest covers the compile-level behavior: both options unset (regression), exact and subpackage generation matching, out-of-scope DTOs getting no builder, usage scope with an existing vs. missing builder, a same-round helper with only the usage scope set, opt-out precedence, custom builderUsageSuffix, external builders without @SimpleBuilder, and both the compiler-arg and inline @SimpleBuilder(options = ...) forms. BuilderScopeResolverTest covers the plain-setter fallback, configuration changes on the same resolver instance, result caching, and registration/scope interaction. PackageScopesTest covers comma-splitting/trimming, blank ⇒ unscoped, exact-vs-subpackage matching, prefix-boundary rejection (com.examplefoo does not match com.example), case-insensitivity, and merge semantics. Processor suite: 547 tests green, full reactor mvn test green.

Docs

docs/CONFIGURATION.md gets "Builder Scoping" sections for builderGenerationPackages, builderUsagePackages, and builderUsageSuffix plus entries in the compiler-option and complete-example references; docs/CUSTOMIZING.md notes that custom generators/enhancers need no changes; README.md gets a "Builder Scoping Example" section linking the demo files in the same style as the other example sections.

AndreasIgel and others added 23 commits August 15, 2026 10:57
The class-level Javadoc and docs/CONFIGURATION.md implied that
@SimpleBuilder is inherited by subclasses, but the annotation was not
meta-annotated with @inherited. As a result BuilderProcessor, which
collects types via RoundEnvironment.getElementsAnnotatedWith(...),
only produced builders for the exact type carrying @SimpleBuilder and
not for unannotated subclasses.

Add @inherited to @SimpleBuilder so subclasses are treated as if they
also carried the annotation, mirroring the existing behaviour of
@SimpleBuilder.Template (which is already @inherited). Update the
Javadoc to document the inheritance explicitly and clarify the
CONFIGURATION.md wording. @Ignore4BuilderGeneration still suppresses
generation for the exact type it is placed on, so opt-outs continue
to work as before.

Add SimpleBuilderInheritanceTest covering direct inheritance, the
opt-out interaction, and multi-level (grandchild) inheritance.

Closes java-helpers#244

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ance

Rename SimpleBuilderInheritanceTest to BuilderAnnotationInheritanceTest
so the name reflects that it covers both builder-triggering annotations.
Add unannotatedSubclassGetsBuilderFromInheritedTemplate, which verifies
that a custom @inherited template annotation (meta-annotated with
@SimpleBuilder.Template) propagates to unannotated subclasses, matching
the existing behaviour of @SimpleBuilder itself.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The Template Javadoc and CONFIGURATION.md "Template Annotations" section
did not explain that @SimpleBuilder.Template is @inherited, nor that a
custom template annotation must additionally declare @inherited to
propagate to unannotated subclasses. Add explicit documentation and an
example showing the @inherited custom annotation pattern.

Also move assertNoBuilderGenerated to ProcessorAsserts so it is shared
by BuilderAnnotationInheritanceTest and Ignore4BuilderGenerationTest
instead of being duplicated as a private helper in each test class.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
While @SimpleBuilder and @inherited template annotations now correctly
trigger builder generation for unannotated subclasses, the configuration
options declared on the parent's @SimpleBuilder(options = ...) or template
are not yet applied to inherited subclass builders — they use default
options instead. This is tracked separately in issue java-helpers#245.

Add caveats to the SimpleBuilder Javadoc, the CONFIGURATION.md Template
Annotations section, and the Template Annotations Not Working
troubleshooting section so users are not surprised by this limitation.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Add explicit guidance that @SimpleBuilder.Template is a meta-annotation
for custom annotation declarations (@interface) only and cannot be
placed directly on a class or record. @SimpleBuilder is for direct
one-off annotation of classes/records.

- SimpleBuilder.java: add 'When to use' section to class-level Javadoc
- SimpleBuilder.Template Javadoc: state it can only be placed on
  annotation types (ANNOTATION_TYPE), not on classes/records
- CONFIGURATION.md 'Template Annotations': add comparison table and
  introductory paragraph
- CONFIGURATION.md troubleshooting: add item about @SimpleBuilder.Template
  not being a class annotation

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…Javadoc

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…arget

The compiler and IDE already enforce @target(ANNOTATION_TYPE) and show
a clear error when @SimpleBuilder.Template is placed on a class/record,
so this troubleshooting item adds no value.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
…rs#114)

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
…a-helpers#114)

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Upstream split the completeness test into buildFullyConfigured() helper;
kept the refactor and added the new builder scoping options to it.
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

*/
public static boolean isInScope(String packageName, Set<String> packageScopes) {
for (String scope : packageScopes) {
if (StringUtils.equalsIgnoreCase(packageName, scope)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already fixed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in an earlier commit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the deprecated StringUtils calls were replaced with Strings.CI equivalents in an earlier commit.

public static boolean isInScope(String packageName, Set<String> packageScopes) {
for (String scope : packageScopes) {
if (StringUtils.equalsIgnoreCase(packageName, scope)
|| StringUtils.startsWithIgnoreCase(packageName, scope + ".")) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already done...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in an earlier commit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the string getters were removed in the latest commit. Only the set getters remain.

StringUtils.equalsIgnoreCase/startsWithIgnoreCase are deprecated in
commons-lang3 in favor of Strings.CI.
AndreasIgel and others added 17 commits September 13, 2026 15:05
The helper is still wanted on this branch; it was dropped together
with other dead-code removals in d58af17.
BuilderConfiguration held a raw comma-separated string plus the
matching/parsing logic, re-parsing on every access. The record now
stores an immutable PackageScopes; scope matching moved out of the
configuration holder.
Restores the historical name from BuilderConfigurationReader and folds
the String.valueOf into it, so the applier wrappers read
parse*(extractEnumName(value)).
apply() already no-ops for options without a builder applier; the
semantic check stays in parseOptionsFromMirror where it feeds the
unknown-option warning.
parseOptionState/parseAccessModifier moved to new OptionValueParsers in
the processing package; the enum now only registers option names and
appliers.
ROUND START log now precedes element collection, and the collection +
sort work is measured as a new top-level "Element Collection" phase so
the report covers the whole round.
The unscoped shortcut, package extraction and skip logging are scope
semantics and belong to the resolver; the processor now asks
isInGenerationScope(element, config) directly.
- Add assertNotesInOrder to ProcessorAsserts: asserts exact note count
  and ordered substring match so any added, removed, or modified log
  message fails the test
- Rewrite BuilderProcessorTest verbose test to use assertNotesInOrder
  with all 65 expected notes in order
- Remove duplicate "Configuration resolved" log from BuilderProcessor
  (already logged by BuilderConfigurationReader as operation closer)
- Update DEBUG_LOGGING.md and CONTRIBUTING.md examples to include
  scope-filter summary and second-round messages
- Update CONFIGURATION.md phase list to include Element Collection

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
AndreasIgel and others added 9 commits September 13, 2026 21:25
…ckerTest

Add two dedicated tests covering the Element Collection phase:
- Text report contains the phase name
- JSON phaseBreakdown includes the phase with elapsedNanos/seconds/percentage

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
resolveUsableBuilderType reads config from context and assumes
initConfigurationForProcessingTarget was already called; isInGenerationScope
takes config as explicit parameter because it runs before that init.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Production code:
- OptionValueParsers: log warnings for unrecognized non-blank values before
  falling back to UNSET/DEFAULT; logger is now a required parameter (no more
  no-logger overloads or null checks)
- CompilerArgumentsEnum: thread ProcessingLogger through apply() via new
  OptionApplier functional interface; remove no-logger apply overload
- CompilerArgumentsReader: replace Arrays.stream with Stream.of; remove
  no-arg readBuilderConfiguration overload and test-only readOptionState/
  readAccessModifier convenience methods
- BuilderConfigurationReader: remove .stream() from forEach; extract lambda
  body to applyOption helper method; pass logger to apply()
- BuilderConfiguration: remove raw string getters getBuilderGenerationPackages()
  and getBuilderUsagePackages(); use getBuilderGenerationPackagesSet() /
  getBuilderUsagePackagesSet() instead
- SimpleBuilder.java: clarify builderUsagePackages default javadoc

Tests:
- CapturingProcessingLogger: new shared test helper in testing package,
  replacing duplicated CapturingMessager/createLogger boilerplate
- OptionValueParsersTest: new test class covering parsing and warning logging
- ActivePerformanceTrackerTest: use CapturingProcessingLogger; replace
  cryptic ternary with explicit loop and clarifying comments
- CompilerArgumentsReaderTest: switch to set getters; add assertion
  messages; remove redundant readOptionState/readAccessModifier tests
  (parsing logic is covered by OptionValueParsersTest)
- CompilerArgumentsEnumTest: pass logger to apply()
- ConfigurationProcessingTest: undo formatter-induced string concatenations;
  switch from removed string getters to set getters
- BuilderConfigurationReaderTest: remove redundant scope options from test
- BuilderScopeResolverTest: rename tests to match behavior; add assertNotSame
  for cache invalidation verification

412 processor tests pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…javadoc

The name isBuilderOption was ambiguous — it could mean "an option of the
builder processor" (which includes process flags) or "an option that
configures the builder" (which excludes them). hasValueApplier is positive
and describes the mechanism: whether the enum constant has an applier that
maps the raw option value to a BuilderConfiguration.Builder setter.

Added javadoc explaining why not all options have an applier: process-control
flags (VERBOSE, PERFORMANCE_TRACKING, PERFORMANCE_OUTPUT_FILE,
DEACTIVATE_GENERATION_COMPONENTS) are read directly via
CompilerArgumentsReader.readValue/readBooleanValue instead of being applied
to a builder configuration.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
even Non-SimpleBuilder Builders could be used
it should not depend on the SimpleBuilder annotation
Builder-Name suffix should be possibly different on consumed builders
generated builders should be trusted (but if they are not in usage scope, they should be ignored too)
Normalize package scopes for stable output and keep parsing and matching
coverage with the PackageScopes abstraction. Remove redundant configuration
scope helpers and their duplicate tests.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Replace formatter-specific logging stubs with CapturingProcessingLogger and
remove the obsolete note assertion helper now superseded by ordered checks.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@AndreasIgel
AndreasIgel merged commit 1acdb6b into java-helpers:main Sep 19, 2026
6 checks passed
@AndreasIgel
AndreasIgel deleted the devin/builder-scope branch September 19, 2026 15:06
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.

3 participants