diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java
index 1a2ed95..cfb8b0c 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java
@@ -29,8 +29,14 @@
import de.splatgames.aether.datafixers.api.schema.SchemaRegistry;
import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
import de.splatgames.aether.datafixers.core.schema.SimpleSchemaRegistry;
+import de.splatgames.aether.datafixers.schematools.analysis.CoverageGap;
+import de.splatgames.aether.datafixers.schematools.analysis.FixCoverage;
+import de.splatgames.aether.datafixers.schematools.analysis.MigrationAnalyzer;
import org.jetbrains.annotations.NotNull;
+import java.util.Comparator;
+import java.util.List;
+
/**
* Fluent API for validating schemas and schema registries.
*
@@ -365,20 +371,84 @@ private ValidationResult validateRegistry(
/**
* Validates that all type changes between schema versions have corresponding DataFixes.
*
- *
This method uses the {@link de.splatgames.aether.datafixers.schematools.analysis.MigrationAnalyzer}
- * to analyze the migration path and identify any coverage gaps where types changed
- * without a DataFix to handle the migration.
- *
- *
Note: This is currently a placeholder implementation.
- * Full integration with MigrationAnalyzer is pending.
+ *
This method uses the {@link MigrationAnalyzer} to analyze the migration path
+ * and identify any coverage gaps where types changed without a DataFix to handle
+ * the migration.
*
* @return validation result containing coverage gap issues, never {@code null}
*/
@NotNull
private ValidationResult validateFixCoverageInternal() {
- // This will be fully implemented when MigrationAnalyzer is complete
- // For now, return empty to allow the API to compile and work
- // The actual coverage analysis requires MigrationAnalyzer which will be created next
- return ValidationResult.empty();
+ // Get version range from the registry
+ final List schemas = this.registry.stream().toList();
+ if (schemas.size() < 2) {
+ // Not enough schemas to analyze coverage
+ return ValidationResult.empty();
+ }
+
+ // Determine version range
+ final int minVersion = schemas.stream()
+ .map(s -> s.version().getVersion())
+ .min(Comparator.naturalOrder())
+ .orElse(0);
+ final int maxVersion = schemas.stream()
+ .map(s -> s.version().getVersion())
+ .max(Comparator.naturalOrder())
+ .orElse(0);
+
+ if (minVersion == maxVersion) {
+ return ValidationResult.empty();
+ }
+
+ // Use MigrationAnalyzer to analyze coverage
+ final MigrationAnalyzer analyzer = MigrationAnalyzer.forRegistries(
+ this.registry,
+ this.fixerBuilder.getFixRegistry()
+ );
+
+ final FixCoverage coverage = analyzer
+ .from(new DataVersion(minVersion))
+ .to(new DataVersion(maxVersion))
+ .includeFieldLevel(true)
+ .analyzeCoverage();
+
+ if (coverage.isFullyCovered()) {
+ return ValidationResult.empty();
+ }
+
+ // Convert coverage gaps to validation issues
+ final ValidationResult.Builder resultBuilder = ValidationResult.builder();
+
+ for (final CoverageGap gap : coverage.gaps()) {
+ final String location = String.format(
+ "v%d -> v%d",
+ gap.sourceVersion().getVersion(),
+ gap.targetVersion().getVersion()
+ );
+
+ final String message = gap.fieldName()
+ .map(field -> String.format(
+ "Missing DataFix for type '%s' field '%s': %s",
+ gap.type().getId(),
+ field,
+ gap.reason().description()
+ ))
+ .orElseGet(() -> String.format(
+ "Missing DataFix for type '%s': %s",
+ gap.type().getId(),
+ gap.reason().description()
+ ));
+
+ resultBuilder.add(
+ ValidationIssue.warning("COVERAGE_MISSING_FIX", message)
+ .at(location)
+ .withContext("type", gap.type().getId())
+ .withContext("reason", gap.reason().name())
+ .withContext("sourceVersion", gap.sourceVersion().getVersion())
+ .withContext("targetVersion", gap.targetVersion().getVersion())
+ );
+ }
+
+ return resultBuilder.build();
}
}
diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
index 088f7bb..843a87e 100644
--- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
+++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
@@ -297,13 +297,9 @@ private class DefaultMigrationRequestBuilder implements MigrationRequestBuilder
private String domain = DataFixerRegistry.DEFAULT_DOMAIN;
/**
- * Optional custom DynamicOps implementation.
- * Reserved for future use when custom ops support is implemented.
+ * Optional custom DynamicOps implementation for format conversion.
+ * When set, the input data will be converted to this format before migration.
*/
- @SuppressFBWarnings(
- value = "URF_UNREAD_FIELD",
- justification = "Field is part of the public API (withOps method); implementation pending."
- )
@Nullable
private DynamicOps> ops;
@@ -395,6 +391,7 @@ public MigrationRequestBuilder withOps(@NotNull final DynamicOps ops) {
*
Validates the builder configuration
*
Resolves the DataFixer from the registry
*
Resolves the target version if "toLatest" was specified
+ *
Converts the input data to the specified DynamicOps format (if configured)
*
Executes the migration with timing
*
Records metrics if available
*
Returns a success or failure result
@@ -422,7 +419,10 @@ public MigrationResult execute() {
final Instant start = Instant.now();
try {
- final TaggedDynamic result = fixer.update(this.data, from, to);
+ // Convert to target format if custom ops are specified
+ final TaggedDynamic inputData = convertToTargetOps(this.data);
+
+ final TaggedDynamic result = fixer.update(inputData, from, to);
final Duration duration = Duration.between(start, Instant.now());
LOG.debug("Migration completed successfully in {}ms", duration.toMillis());
@@ -451,6 +451,31 @@ public MigrationResult execute() {
}
}
+ /**
+ * Converts the input data to the target DynamicOps format if custom ops are specified.
+ *
+ *
If no custom ops are configured, the original data is returned unchanged.
+ * Otherwise, the data's Dynamic value is converted to the new format using
+ * {@link de.splatgames.aether.datafixers.api.dynamic.Dynamic#convert(DynamicOps)}.
+ *
+ * @param input the input TaggedDynamic to potentially convert
+ * @return the converted TaggedDynamic, or the original if no conversion is needed
+ */
+ @NotNull
+ private TaggedDynamic convertToTargetOps(@NotNull final TaggedDynamic input) {
+ if (this.ops == null) {
+ return input;
+ }
+
+ @SuppressWarnings("unchecked")
+ final DynamicOps