diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java index 890109b..fde211a 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java @@ -27,6 +27,7 @@ import de.splatgames.aether.datafixers.api.fix.DataFixer; import de.splatgames.aether.datafixers.api.schema.Schema; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Objects; @@ -171,7 +172,7 @@ public int compareTo(@NotNull final DataVersion o) { * @see #hashCode() */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -222,6 +223,7 @@ public int hashCode() { * @return a string representation of this data version in the format {@code "DataVersion{version=N}"} */ @Override + @NotNull public String toString() { return "DataVersion{" + "version=" + this.version + '}'; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java index 5aaedb9..37d1f52 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java @@ -28,6 +28,7 @@ import de.splatgames.aether.datafixers.api.type.Type; import de.splatgames.aether.datafixers.api.type.TypeRegistry; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A unique identifier for a data type in the data fixing system. @@ -183,7 +184,7 @@ public int hashCode() { * @see #hashCode() */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -217,6 +218,7 @@ public boolean equals(final Object obj) { * @see #getId() */ @Override + @NotNull public String toString() { return "TypeReference{" + "id='" + this.id + '\'' + '}'; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java index 444fa89..f7a2d1b 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java @@ -53,6 +53,10 @@ * @param captureRuleDetails whether to capture individual rule application details * @param maxSnapshotLength maximum length for snapshot strings (0 for unlimited) * @param prettyPrintSnapshots whether to format snapshots for readability + * @param captureFieldDetails whether to capture field-level operation metadata from + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} + * implementations; requires {@code captureRuleDetails} to be + * {@code true} to have any effect (since 1.0.0) * @author Erik Pförtner * @see DiagnosticContext * @see MigrationReport @@ -62,7 +66,8 @@ public record DiagnosticOptions( boolean captureSnapshots, boolean captureRuleDetails, int maxSnapshotLength, - boolean prettyPrintSnapshots + boolean prettyPrintSnapshots, + boolean captureFieldDetails ) { /** @@ -79,13 +84,14 @@ public record DiagnosticOptions( *
When enabled and {@code captureRuleDetails} is also enabled, the migration + * report will include structured metadata about which fields each rule affects. + * This information is extracted from rules that implement + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule}.
+ * + * @param captureFieldDetails {@code true} to capture field-level details + * @return this builder + * @since 1.0.0 + */ + @NotNull + public Builder captureFieldDetails(final boolean captureFieldDetails) { + this.captureFieldDetails = captureFieldDetails; + return this; + } + /** * Builds the diagnostic options. * @@ -208,7 +234,8 @@ public DiagnosticOptions build() { this.captureSnapshots, this.captureRuleDetails, this.maxSnapshotLength, - this.prettyPrintSnapshots + this.prettyPrintSnapshots, + this.captureFieldDetails ); } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java new file mode 100644 index 0000000..267cba0 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Metadata about a single field-level operation within a + * {@link TypeRewriteRule} application. + * + *{@code FieldOperation} captures structured information about which fields + * are affected by a rule and how. This enables field-level diagnostic reporting beyond the type-level granularity of + * {@link RuleApplication}.
+ * + *{@code
+ * MigrationReport report = context.getReport();
+ * for (FixExecution fix : report.fixExecutions()) {
+ * for (RuleApplication rule : fix.ruleApplications()) {
+ * for (FieldOperation fieldOp : rule.fieldOperations()) {
+ * System.out.println(fieldOp.operationType() + " on " +
+ * String.join(".", fieldOp.fieldPath()));
+ * }
+ * }
+ * }
+ * }
+ *
+ * Field paths are represented as a list of path segments. A top-level field + * {@code "name"} is represented as {@code ["name"]}, while a nested field {@code "position.x"} is represented as + * {@code ["position", "x"]}.
+ * + * @param operationType the kind of field operation, must not be {@code null} + * @param fieldPath the path segments to the affected field, must not be {@code null} or empty + * @param targetFieldName the target field name for operations that have one (e.g., rename target, move/copy destination + * as dot-notation path); {@code null} for operations without a target + * @param description optional human-readable description providing additional context; may be {@code null} + * @author Erik Pförtner + * @see FieldOperationType + * @see RuleApplication#fieldOperations() + * @see FieldAwareRule + * @since 1.0.0 + */ +public record FieldOperation( + @NotNull FieldOperationType operationType, + @NotNull ListThis represents grouping multiple source fields into a nested object + * at the target field name.
+ * + * @param targetField the name of the new nested object field, must not be {@code null} + * @param sourceFields the fields to group into the target, must not be {@code null} or empty + * @return a new group field operation + * @throws NullPointerException if any argument is {@code null} + * @throws IllegalArgumentException if {@code sourceFields} is empty + */ + @NotNull + public static FieldOperation group(@NotNull final String targetField, + @NotNull final String... sourceFields) { + Preconditions.checkNotNull(targetField, "targetField must not be null"); + Preconditions.checkNotNull(sourceFields, "sourceFields must not be null"); + Preconditions.checkArgument(sourceFields.length > 0, "sourceFields must not be empty"); + return new FieldOperation( + FieldOperationType.GROUP, + List.copyOf(Arrays.asList(sourceFields)), + targetField, + null + ); + } + + /** + * Creates a flatten field operation. + * + * @param fieldName the name of the nested object to flatten, must not be {@code null} + * @return a new flatten field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation flatten(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.FLATTEN, List.of(fieldName), null, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Conditional Operations + // ------------------------------------------------------------------------- + + /** + * Creates a conditional field operation. + * + * @param fieldName the field name that the condition checks, must not be {@code null} + * @param conditionType a description of the condition (e.g., {@code "exists"}, {@code "missing"}, + * {@code "equals"}), must not be {@code null} + * @return a new conditional field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation conditional(@NotNull final String fieldName, + @NotNull final String conditionType) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + Preconditions.checkNotNull(conditionType, "conditionType must not be null"); + return new FieldOperation( + FieldOperationType.CONDITIONAL, + List.of(fieldName), + null, + conditionType + ); + } + + // ------------------------------------------------------------------------- + // Convenience Methods + // ------------------------------------------------------------------------- + + /** + * Parses a dot-notation path string into a list of path segments. + * + * @param path the dot-notation path (e.g., {@code "position.x"}) + * @return list of path segments (e.g., {@code ["position", "x"]}) + */ + @NotNull + private static ListFor example, a path of {@code ["position", "x"]} returns {@code "position.x"}.
+ * + * @return the dot-notation field path, never {@code null} + */ + @NotNull + public String fieldPathString() { + return String.join(".", this.fieldPath); + } + + /** + * Returns whether this operation targets a nested field (path depth > 1). + * + * @return {@code true} if the field path has more than one segment + */ + public boolean isNested() { + return this.fieldPath.size() > 1; + } + + // ------------------------------------------------------------------------- + // Internal Helpers + // ------------------------------------------------------------------------- + + /** + * Returns a human-readable summary of this field operation. + * + * @return formatted summary string, never {@code null} + */ + @NotNull + public String toSummary() { + final StringBuilder sb = new StringBuilder(); + sb.append(this.operationType.name()).append('(').append(fieldPathString()); + if (this.targetFieldName != null) { + sb.append(" -> ").append(this.targetFieldName); + } + sb.append(')'); + if (this.description != null) { + sb.append(" [").append(this.description).append(']'); + } + return sb.toString(); + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java new file mode 100644 index 0000000..ecd7f5a --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import de.splatgames.aether.datafixers.api.rewrite.Rules; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; + +/** + * Classifies the kind of field-level operation performed by a + * {@link TypeRewriteRule} during a migration. + * + *Each constant corresponds to one or more factory methods in + * {@link Rules}:
+ * + *| Type | Rules method(s) |
|---|---|
| {@link #RENAME} | {@code renameField}, {@code renameFields}, {@code renameFieldAt} |
| {@link #REMOVE} | {@code removeField}, {@code removeFields}, {@code removeFieldAt} |
| {@link #ADD} | {@code addField}, {@code addFieldAt} |
| {@link #TRANSFORM} | {@code transformField}, {@code transformFieldAt} |
| {@link #SET} | {@code setField} |
| {@link #MOVE} | {@code moveField} |
| {@link #COPY} | {@code copyField} |
| {@link #GROUP} | {@code groupFields} |
| {@link #FLATTEN} | {@code flattenField} |
| {@link #CONDITIONAL} | {@code ifFieldExists}, {@code ifFieldMissing}, {@code ifFieldEquals} |
For example, {@link #RENAME} returns {@code "rename"} and + * {@link #CONDITIONAL} returns {@code "conditional"}.
+ * + * @return the display name, never {@code null} + */ + @NotNull + public String displayName() { + return this.displayName; + } + + /** + * Returns whether this operation type requires a target field name. + * + *Operations that produce a target include:
+ *Structural operations change the shape of the data (nesting, flattening, moving fields + * between levels), while non-structural operations modify field values in place or add/remove + * individual fields at the same level.
+ * + *Structural types: {@link #MOVE}, {@link #COPY}, {@link #GROUP}, {@link #FLATTEN}.
+ * + * @return {@code true} if this is a structural operation + */ + public boolean isStructural() { + return this == MOVE || this == COPY || this == GROUP || this == FLATTEN; + } + + /** + * Returns the human-readable display name. + * + * @return the display name, never {@code null} + */ + @Override + @NotNull + public String toString() { + return this.displayName; + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java index 44f24cf..6e5df6f 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java @@ -152,6 +152,31 @@ public int matchedRuleCount() { .count(); } + /** + * Returns all field operations across all rule applications in this fix. + * + * @return unmodifiable list of all field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public ListThe {@link #diagnosticFieldOperations()} method provides structured metadata + * about all batch operations for the diagnostic system. When used via {@link Rules#batch}, + * this metadata is automatically attached to the resulting rule as + * {@link FieldOperation} records.
+ * *This class is not thread-safe during construction. Once built into a * {@link TypeRewriteRule} via {@link Rules#batch}, the resulting rule is thread-safe.
@@ -79,7 +87,7 @@ */ public final class BatchTransformThis method converts the internal batch operations to + * {@link FieldOperation} records + * for use by the diagnostic system. Each batch operation is mapped to its + * corresponding diagnostic field operation type.
+ * + * @return an unmodifiable list of diagnostic field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public List{@code FieldAwareRule} is a separate interface rather than a default method + * on {@link TypeRewriteRule} because {@code TypeRewriteRule} is a + * {@link FunctionalInterface} and cannot have additional abstract methods. + * Rules created by the field operation methods in {@link Rules} (such as + * {@code renameField}, {@code removeField}, etc.) implement both + * {@code TypeRewriteRule} and {@code FieldAwareRule}.
+ * + *The diagnostic system uses {@code instanceof FieldAwareRule} to detect + * whether a rule carries field-level metadata. When present, the metadata is + * included in the {@link RuleApplication} + * record for that rule execution.
+ * + *{@code
+ * TypeRewriteRule rule = Rules.renameField(ops, "oldName", "newName");
+ *
+ * if (rule instanceof FieldAwareRule fieldAware) {
+ * List fieldOps = fieldAware.fieldOperations();
+ * // fieldOps contains: [FieldOperation.rename("oldName", "newName")]
+ * }
+ * }
+ *
+ * Custom rules can implement this interface to participate in field-level + * diagnostics:
+ *{@code
+ * public class MyCustomRule implements TypeRewriteRule, FieldAwareRule {
+ * @Override
+ * public Optional> rewrite(Type> type, Typed> input) {
+ * // rule logic
+ * }
+ *
+ * @Override
+ * public List fieldOperations() {
+ * return List.of(FieldOperation.transform("myField"));
+ * }
+ * }
+ * }
+ *
+ * @author Erik Pförtner
+ * @see TypeRewriteRule
+ * @see FieldOperation
+ * @see Rules
+ * @since 1.0.0
+ */
+public interface FieldAwareRule {
+
+ /**
+ * Returns the list of field-level operations that this rule performs.
+ *
+ * The returned list describes which fields are affected and how. For + * simple rules (e.g., a single {@code renameField}), this typically contains + * one entry. For batch rules (e.g., {@code renameFields} with multiple + * mappings), this may contain multiple entries.
+ * + *The returned list must be immutable.
+ * + * @return an unmodifiable list of field operations, never {@code null} + */ + @NotNull + ListThe {@code Rules} class is a comprehensive toolkit for constructing data migration rules. - * It provides a rich set of combinators that allow complex migration logic to be built from simple, composable - * primitives. These combinators follow functional programming patterns and enable declarative specification of data - * transformations.
+ *{@code Rules} is the canonical entry point for constructing data migration + * logic in Aether Datafixers. It exposes a rich, type-safe DSL of small, + * composable primitives that can be combined into arbitrarily complex + * transformations. Every factory method here returns a stateless, thread-safe + * {@link TypeRewriteRule} that can be reused across migrations.
+ * + *Every field-operation method (rename, remove, add, transform, batch + * variants, path-based variants, and conditionals) returns a rule that + * implements {@link FieldAwareRule} and carries structured + * {@link FieldOperation} metadata. When such a rule runs inside a + * {@link DiagnosticContext}, the + * resulting {@link MigrationReport + * MigrationReport} captures exactly which fields were touched and how — not + * merely that a rule ran. The composition combinators + * ({@link #seq}, {@link #seqAll}, {@link #choice}, {@link #batch}) + * transparently aggregate this metadata from their children, so a single + * composed rule surfaces all of its sub-operations as a unified group.
* *These combinators stitch other rules together. They preserve and aggregate + * field operation metadata from their children, so a {@code seq} of three + * field-aware rules surfaces as a single rule whose + * {@link FieldAwareRule#fieldOperations()} contains all three operations + * flattened in order.
*{@code
- * // Build a complex migration rule using combinators
- * TypeRewriteRule migration = Rules.seq(
- * // First, rename the old field
- * Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
+ * 2. Traversal Combinators
+ * These walk recursive data structures and apply a rule at one or more
+ * positions. Each combinator has two overloads: one with an explicit
+ * {@link DynamicOps} for {@link Dynamic}-based traversal, and a higher-level
+ * overload that operates on the {@link Type} system.
+ * These are the most common building blocks. Each operates on a single, + * top-level field of a {@link Dynamic} map and returns a + * {@link FieldAwareRule} carrying the corresponding {@link FieldOperation}.
+ *Equivalents that operate on many fields at once for performance — useful + * when migrating dozens of fields in the same step. They return a single rule + * whose field-operation metadata contains one entry per affected field.
+ *Variants of the top-level operations that accept a dot-notation path + * (e.g. {@code "position.x"}) for navigating into nested objects. Internally + * they use {@link Finder} optics; the resulting rule carries a + * {@link FieldOperation} whose {@code fieldPath} reflects the nested structure.
+ *Apply an inner rule only when a field-level condition is satisfied. These + * are typically composed with the field combinators above to express "migrate + * X only when Y looks like Z" patterns. The diagnostic metadata records the + * condition itself as a {@link FieldOperation} of type + * {@link FieldOperationType#CONDITIONAL}.
+ *{@code
+ * // Migrate a player save from v1 to v2:
+ * // - rename "playerName" to "name"
+ * // - drop the legacy "lastSeen" field
+ * // - regroup x/y/z coordinates into a nested "position" object
+ * // - add a default "health" field
+ * // - bump "level" by one — but only if it currently exists
+ * // - all bundled into a single sequence so the diagnostics report
+ * // attributes every change to the migration step.
+ * TypeRewriteRule playerV1ToV2 = Rules.seq(
+ * Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
+ * Rules.removeField(GsonOps.INSTANCE, "lastSeen"),
+ * Rules.groupFields(GsonOps.INSTANCE, "position", "x", "y", "z"),
+ * Rules.addField(GsonOps.INSTANCE, "health",
+ * new Dynamic<>(GsonOps.INSTANCE, GsonOps.INSTANCE.createInt(100))),
+ * Rules.ifFieldExists(GsonOps.INSTANCE, "level",
+ * Rules.transformField(GsonOps.INSTANCE, "level",
+ * d -> d.createInt(d.asInt().result().orElse(0) + 1)))
* );
*
- * // Apply the migration
- * Typed> result = migration.apply(inputData);
+ * // When run with a DiagnosticContext, the resulting MigrationReport contains
+ * // one FieldOperation per top-level rule above (5 entries: RENAME, REMOVE,
+ * // GROUP, ADD, CONDITIONAL — the inner TRANSFORM is recorded under the
+ * // CONDITIONAL wrapper).
* }
*
- * For recursive data structures:
+ *For recursive structures (lists, nested maps, sums of types):
*Rules created via {@link #dynamicTransform} or by hand-implementing + * {@link TypeRewriteRule} are not field-aware by default — the + * diagnostic system records them but cannot break them down by field. If you + * write a custom rule and want diagnostic visibility, also implement + * {@link FieldAwareRule} and return the operations your rule performs from + * {@link FieldAwareRule#fieldOperations()}.
+ * *All factory methods return stateless, thread-safe rules. The same rule - * instance can be used concurrently for multiple migrations.
+ *All factory methods return stateless, thread-safe rules. A rule built + * once at application start can be reused concurrently for any number of + * migrations. The internal {@link Finder} cache used by the path-based + * methods is also thread-safe.
+ * + *If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata. This allows the diagnostic system to report which fields are + * affected even through compositions.
+ * * @param rules the rules to apply in sequence; if empty, returns identity rule * @return a composed rule requiring all rules to match, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -151,7 +340,8 @@ public static TypeRewriteRule seq(@NotNull final TypeRewriteRule... rules) { if (rules.length == 1) { return rules[0]; } - return new TypeRewriteRule() { + final String ruleName = "seq(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public OptionalIf any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata for diagnostic reporting.
+ * * @param rules the rules to try in sequence; non-matching rules are skipped * @return a composed rule that always succeeds, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -203,7 +403,8 @@ public String toString() { @NotNull public static TypeRewriteRule seqAll(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "seqAll(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public OptionalIf any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata from all alternatives, since any one of them may match at + * runtime.
+ * * @param rules the rules to try in order; first match wins * @return a composed rule that uses the first matching rule, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -251,7 +463,8 @@ public String toString() { @NotNull public static TypeRewriteRule choice(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "choice(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public OptionalThe returned rule implements {@link FieldAwareRule} with field operation + * metadata derived from the batch operations (rename, remove, set, transform, addIfMissing).
+ * * @paramThis is used by composition methods ({@link #seq}, {@link #seqAll}, {@link #choice}) + * to aggregate field-level metadata from their children into the composed rule.
+ * + * @param rules the child rules to inspect + * @return an unmodifiable list of aggregated field operations; empty if no children are field-aware + * @since 1.0.0 + */ + @NotNull + private static List