Skip to content

Seed referenced builders via field functions when no copy path exists (#310) - #321

Merged
AndreasIgel merged 14 commits into
java-helpers:mainfrom
igel-devin-ai:devin/1791048085-field-function-seeding
Oct 3, 2026
Merged

AndreasIgel merged 14 commits into
java-helpers:mainfrom
igel-devin-ai:devin/1791048085-field-function-seeding

Conversation

@igel-devin-ai

@igel-devin-ai igel-devin-ai commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Implements #310 (last sub-task of #301).

Summary

Builders that satisfy the empty-instantiation + build() contract but offer no prefilled path (no ctor(T), no static T-factory, no toBuilder()) previously resolved but skipped every consumer helper — the field-silent-loss issue #310 describes. This adds a fourth prefilled-path tier: field-function prefilling, controlled by a new usingFieldFunctionPrefill option (default ENABLED for @SimpleBuilder, disabled by @SimpleMinimalBuilder; only consulted when usingExistingBuilders is enabled, since the per-type analysis adds compile cost).

  • JavaLangAnalyser.findReadableProperties enumerates a type's readable properties in declaration order (record components → x(), getX()/isX() methods → x(), fields → x; first source wins).
  • The resolver then detects the builder's field-function convention: candidates are name(v) (fluent) and setName(v) (JavaBeans) plus any other camel-case prefix found on the builder's single-parameter methods, ranked by how many properties each actually covers. The detected prefix is stored on PrefillCall inside ResolvedBuilder.
  • Majority coverage: a convention is trusted once it covers strictly more than half the readable properties — a property the builder does not accept (e.g. a computed getter) no longer blocks the path. Covered fields are prefilled param-type-exact; the rest stay unset. Below the majority — or the option disabled — the builder resolves degraded (funcForPrefilledBuilder = Optional.empty()), so helpers that would silently drop state stay skipped and a debug note is logged.
  • FieldPrefillEnhancer (priority 15, dedup via distinct()) emits one private static prefill<Builder>(T value) method per referenced builder type, ordered last in the generated class (below toString).
  • Bonus: FreeBuilder (setName-style), AutoValue-fluent, and withX-style builder shapes now resolve where they previously fell back.

Design notes

  • Prefilled-path search order is unchanged: builder's own contract path → anchored static builder(T) → toBuilder() → field functions (last, since it produces per-property calls). Anchored static/instance lookups are separate named functions, no Optional.or chains.
  • The anchored path stays strict — an anchored builder without a prefilled path still falls through rather than degrading.
  • Several fields referencing the same type share one prefill method; same builder type for different source types resolves via ordinary Java overloading.

Verification

  • 510 processor tests green, incl. resolver tests for fluent/set/detected-prefix shapes, single-property prefix acceptance, majority-vs-uncovered coverage, record/field/isX properties, and the disabled gate — plus generated-source assertions on the emitted prefill<Builder> method.
  • Example generated builders byte-identical (no prefill-eligible builders in use).
  • Sonar: re-analyzed after each push; latest run clean.

@codecov

codecov Bot commented Oct 3, 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!

Builders lacking a seeded path (ctor(T), static T-factory, toBuilder) are
now tried against field-function seeding: when every readable property of
the referenced type has a matching name(v)/setName(v) function on the
builder, the generated builder emits a private seed<Builder>(T) method
that obtains an empty builder and calls one field function per property.
Builders whose properties are only partially covered still resolve but
are marked degraded, so consumer helpers that would silently drop state
are skipped with a debug note.
- propertyNameOf only strips get/is before an uppercase letter, so
  accessors like island()/getting() keep their own name.
- Resolver tests now exercise record components, direct fields, and
  isX() boolean accessors on top of the getter-based shapes.
…/setName

The resolver now collects the builder's single-parameter methods and
derives candidate naming conventions: '' (fluent name(v)) and 'set'
(JavaBeans) first, then any other camel-case prefix shared by at least
two methods (e.g. withName(v) as builder frameworks emit). The first
prefix covering every readable property wins and is stored on
FieldSeedingCall inside ResolvedBuilder; SeededField reduces to
(property, accessor) and the emitted call is derived via
builderMethodFor().
Candidate prefixes are now sorted by how many readable properties each
actually covers instead of a fixed '' -> set -> detected order; '' and
'set' remain always-tried candidates and only win on ties. The matching
logic is shared by coveredCount and matchByPrefix via findFieldFunction.
A detected prefix now needs at least min(2, properties) occurrences, so
a single-property type resolving against one withX-style method is
accepted instead of rejected; for several properties full coverage
already implies the count.
… off)

The seeding analysis (property enumeration + builder method scan + prefix
ranking) is per-type compile cost, so it is opt-in: a new OptionState on
@SimpleBuilder.Options / BuilderConfiguration / -Asimplebuilder.
usingFieldFunctionSeeding, only consulted when usingExistingBuilders is
enabled. Disabled, builders without a seeded path degrade as before.
Resolver and processing tests enable the flag; a probe asserts the
disabled gate.
- Emit 'prefill<Builder>' (private static, ordered last) instead of
  'seed<Builder>' for consistency with funcForPrefilledBuilder.
- Trust a field-function convention once it covers the majority of the
  readable properties; uncovered properties stay unset.
- Reorder resolveByBuilderContract so the empty-path gate precedes the
  prefilled checks; factor anchored static/instance factory lookups into
  named methods instead of an Optional.or chain; collectSeededFields
  returns a list reused by coveredCount; Optional.ofNullable in
  ResolvedBuilder; StringUtils.substring for prefix extraction;
  single-colon filter predicates.
Matches the emitted prefill<Builder> naming. The option is now ENABLED
in BuilderConfiguration.DEFAULT for @SimpleBuilder and explicitly
DISABLED in @SimpleMinimalBuilder, keeping its feature set minimal.
FieldSeedingCall -> PrefillCall, SeededField -> PrefilledField,
FieldSeedingEnhancer -> FieldPrefillEnhancer, resolveFieldSeeding ->
resolveFieldFunctionPrefill, plus prose: seeded path -> prefilled path,
seeding -> prefilling. Matches usingFieldFunctionPrefill and the emitted
prefill<Builder> methods.
Review fixes: replace the hand-rolled firstUpperCaseIndex loop with
StringUtils.indexOfAny, and the negated lambda with
Predicate.not(prefixes::contains).
@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1791048085-field-function-seeding branch from 23a2543 to d5b005d Compare October 3, 2026 20:36
…Fields, guard record components

- S3776: extract unwrapContainerInstances from findAnnotationInstances
  (cognitive complexity 19 -> under limit)
- S9391: stream pipeline in collectPrefilledFields
- S2259: Optional.ofNullable around getRecordComponents()
The flagged deref was the receiver, not the record-components list:
sibling finders already guard a null type the same way.
…nvention

- 'consumer helpers are skipped' debug only when usingFieldFunctionPrefill
  is enabled
- fieldFunctions collection becomes JavaLangAnalyser.findFieldFunctions
- all candidate conventions are scored and the best-covered one wins;
  Optional.empty only when the best misses the majority - empty/set keep
  tie priority through the stable sort
- rename PrefilledField to Field: PrefillCall.Field carries the context
- static prefilledField factory makes the coverage map a :: reference
- test fixture builders now retain their values so build() produces a
  populated instance - the mock builders act as real builders
@sonarqubecloud

sonarqubecloud Bot commented Oct 3, 2026

Copy link
Copy Markdown

@AndreasIgel
AndreasIgel merged commit 47bf5b7 into java-helpers:main Oct 3, 2026
5 checks passed
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.

2 participants