diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContext.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContext.java index 29fc628..a761e27 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContext.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContext.java @@ -26,6 +26,8 @@ import de.splatgames.aether.datafixers.api.fix.DataFixerContext; import org.jetbrains.annotations.NotNull; +import java.util.ServiceLoader; + /** * A diagnostic-aware context for capturing detailed migration information. * @@ -94,9 +96,14 @@ static DiagnosticContext create() { @NotNull static DiagnosticContext create(@NotNull final DiagnosticOptions options) { Preconditions.checkNotNull(options, "options must not be null"); - // Use ServiceLoader or direct instantiation - // For now, we'll use direct instantiation via the core module - // This will be resolved at runtime by the core implementation + + // Discover implementation via ServiceLoader (preferred over reflection) + for (final DiagnosticContextFactory factory + : ServiceLoader.load(DiagnosticContextFactory.class)) { + return factory.create(options); + } + + // Fallback to reflection for backward compatibility try { final Class implClass = Class.forName( "de.splatgames.aether.datafixers.core.diagnostic.DiagnosticContextImpl" @@ -107,7 +114,8 @@ static DiagnosticContext create(@NotNull final DiagnosticOptions options) { } catch (final ReflectiveOperationException e) { throw new IllegalStateException( "Failed to create DiagnosticContext. " + - "Ensure aether-datafixers-core is on the classpath.", + "Ensure aether-datafixers-core is on the classpath " + + "or register a DiagnosticContextFactory via ServiceLoader.", e ); } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContextFactory.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContextFactory.java new file mode 100644 index 0000000..e67b0c3 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContextFactory.java @@ -0,0 +1,49 @@ +/* + * 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 org.jetbrains.annotations.NotNull; + +/** + * Factory interface for creating {@link DiagnosticContext} instances. + * + *

Implementations are discovered via {@link java.util.ServiceLoader}. Register an + * implementation by creating a file at + * {@code META-INF/services/de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContextFactory} + * containing the fully qualified class name of the factory implementation.

+ * + * @author Erik Pförtner + * @see DiagnosticContext#create(DiagnosticOptions) + * @since 1.0.0 + */ +public interface DiagnosticContextFactory { + + /** + * Creates a new {@link DiagnosticContext} with the specified options. + * + * @param options the diagnostic options, must not be {@code null} + * @return a new diagnostic context, never {@code null} + */ + @NotNull + DiagnosticContext create(@NotNull DiagnosticOptions options); +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/DynamicOps.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/DynamicOps.java index b95e95b..36cbdf9 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/DynamicOps.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/DynamicOps.java @@ -99,12 +99,21 @@ public interface DynamicOps { // ==================== Empty/Null ==================== /** - * Creates an empty value representation. + * Creates an empty/null value representation. * - *

Implementations typically return an empty map/object node, but the exact meaning is - * defined by the concrete ops.

+ *

The returned value is the canonical "null" representation for this format. + * Implementations differ in how they represent null:

+ * + *

When converting between formats via {@link #convertTo}, null representations + * are normalized through this method. Use {@link #isNull(Object)} to check for null + * regardless of the underlying representation.

* - * @return an empty value + * @return the canonical empty/null value for this format */ @NotNull T empty(); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java index aea006b..845c3ed 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java @@ -47,6 +47,9 @@ import java.nio.file.StandardCopyOption; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.Callable; @@ -688,8 +691,10 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co try { Files.writeString(tempPath, content); if (this.backup) { + final String timestamp = ZonedDateTime.now(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")); final Path backupPath = inputFile.toPath().resolveSibling( - inputFile.getName() + ".bak"); + inputFile.getName() + ".bak." + timestamp); Files.move(inputFile.toPath(), backupPath, StandardCopyOption.REPLACE_EXISTING); } Files.move(tempPath, inputFile.toPath(), StandardCopyOption.REPLACE_EXISTING); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java index 6c5ce4a..0dc9442 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java @@ -41,11 +41,15 @@ import java.util.concurrent.Callable; /** - * CLI command to validate data files and check if migration is needed. + * CLI command to check data file versions and determine if migration is needed. * - *

The validate command checks data files against a target schema version - * without performing any modifications. This is useful for batch validation, - * CI/CD pipelines, and pre-migration checks.

+ *

This command validates that data files contain version information and checks + * whether the version is at or above the target version. It does not validate + * schema compliance — only version numbers are checked. For full schema validation, + * use the programmatic {@code SchemaValidator} API from the schema-tools module.

+ * + *

The command is useful for batch pre-migration checks in CI/CD pipelines + * to identify files that need migration without modifying them.

* *

Usage Examples

*
{@code
@@ -81,7 +85,7 @@
  */
 @Command(
         name = "validate",
-        description = "Validate data files and check if migration is needed.",
+        description = "Check data file versions and report which files need migration (does not validate schema compliance).",
         mixinStandardHelpOptions = true
 )
 public class ValidateCommand implements Callable {
diff --git a/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/command/MigrateCommandTest.java b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/command/MigrateCommandTest.java
index 3ed93e0..3ffc0f2 100644
--- a/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/command/MigrateCommandTest.java
+++ b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/command/MigrateCommandTest.java
@@ -214,8 +214,13 @@ void createsBackupFiles() throws IOException {
                     "--from", "1");
 
             assertThat(exitCode).isEqualTo(0);
-            assertThat(Files.exists(file1.resolveSibling("player1.json.bak"))).isTrue();
-            assertThat(Files.exists(file2.resolveSibling("player2.json.bak"))).isTrue();
+            // Backups use timestamped names: player1.json.bak.
+            try (var files1 = Files.list(file1.getParent())) {
+                assertThat(files1.anyMatch(p -> p.getFileName().toString().startsWith("player1.json.bak."))).isTrue();
+            }
+            try (var files2 = Files.list(file2.getParent())) {
+                assertThat(files2.anyMatch(p -> p.getFileName().toString().startsWith("player2.json.bak."))).isTrue();
+            }
         }
 
         @Test
diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
index be50226..00f265c 100644
--- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
+++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
@@ -179,6 +179,11 @@ public static final class BuilderImpl implements MigrationReport.Builder {
         private final List currentRuleApplications = new ArrayList<>();
         private String currentFixBeforeSnapshot;
 
+        // Lifecycle state
+        private boolean migrationStarted;
+        private boolean fixInProgress;
+        private boolean built;
+
         BuilderImpl() {
         }
 
@@ -192,7 +197,10 @@ public Builder startMigration(
             Preconditions.checkNotNull(type, "type must not be null");
             Preconditions.checkNotNull(fromVersion, "fromVersion must not be null");
             Preconditions.checkNotNull(toVersion, "toVersion must not be null");
+            Preconditions.checkState(!this.built, "Report already built");
+            Preconditions.checkState(!this.migrationStarted, "Migration already started");
 
+            this.migrationStarted = true;
             this.type = type;
             this.fromVersion = fromVersion;
             this.toVersion = toVersion;
@@ -212,6 +220,11 @@ public Builder setInputSnapshot(@Nullable final String snapshot) {
         @NotNull
         public Builder startFix(@NotNull final DataFix fix) {
             Preconditions.checkNotNull(fix, "fix must not be null");
+            Preconditions.checkState(!this.built, "Report already built");
+            Preconditions.checkState(this.migrationStarted, "Migration not started");
+            Preconditions.checkState(!this.fixInProgress,
+                    "Fix already in progress: " + this.currentFixName);
+            this.fixInProgress = true;
             this.currentFixName = fix.name();
             this.currentFixFromVersion = fix.fromVersion();
             this.currentFixToVersion = fix.toVersion();
@@ -244,6 +257,7 @@ public Builder endFix(
                 @Nullable final String afterSnapshot
         ) {
             Preconditions.checkNotNull(fix, "fix must not be null");
+            Preconditions.checkState(this.fixInProgress, "No fix in progress");
             Preconditions.checkNotNull(duration, "duration must not be null");
 
             final FixExecution execution = new FixExecution(
@@ -270,6 +284,7 @@ public Builder endFix(
          * to prevent stale state from corrupting subsequent fix records.
          */
         private void resetFixState() {
+            this.fixInProgress = false;
             this.currentFixName = null;
             this.currentFixFromVersion = null;
             this.currentFixToVersion = null;
@@ -304,6 +319,9 @@ public Builder setOutputSnapshot(@Nullable final String snapshot) {
         @Override
         @NotNull
         public MigrationReport build() {
+            Preconditions.checkState(!this.built, "Report already built");
+            Preconditions.checkState(!this.fixInProgress,
+                    "Cannot build report while fix is in progress: " + this.currentFixName);
             if (this.type == null) {
                 throw new IllegalStateException(
                         "Migration was not started. Call startMigration() first."
@@ -311,6 +329,7 @@ public MigrationReport build() {
             }
 
             this.endTime = Instant.now();
+            this.built = true;
             return new MigrationReportImpl(this);
         }
     }
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationAnalyzer.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationAnalyzer.java
index 16601c8..ab3aa41 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationAnalyzer.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationAnalyzer.java
@@ -259,6 +259,13 @@ public MigrationPath analyze() {
     /**
      * Analyzes fix coverage for the migration between configured versions.
      *
+     * 

Known limitation: Coverage analysis operates at the type level, not the + * field level. While {@link CoverageGap.Reason} defines field-level reasons + * ({@code FIELD_ADDED}, {@code FIELD_REMOVED}, {@code FIELD_TYPE_CHANGED}), these are + * not currently populated because the analysis cannot determine which specific fields + * a DataFix handles. If any fix exists for a type at a given version, all field changes + * for that type are considered covered. See {@link #checkTypeDiffCoverage} for details.

+ * * @return the coverage analysis result, never {@code null} * @throws IllegalStateException if from/to versions are not set */ diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/TypeIntrospector.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/TypeIntrospector.java index e3cbed4..2ee7cbb 100644 --- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/TypeIntrospector.java +++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/TypeIntrospector.java @@ -186,6 +186,7 @@ public static TypeKind determineKind(@NotNull final Type type) { // Check reference ID for other types final String refId = type.reference().getId(); + final String description = type.describe(); // Primitive types if (PRIMITIVE_TYPE_IDS.contains(refId)) { @@ -193,7 +194,7 @@ public static TypeKind determineKind(@NotNull final Type type) { } // Passthrough - if ("passthrough".equals(refId) || "...".equals(type.describe())) { + if ("passthrough".equals(refId) || "...".equals(description)) { return TypeKind.PASSTHROUGH; } @@ -218,7 +219,7 @@ public static TypeKind determineKind(@NotNull final Type type) { } // Named type (has "=" in description) - if (type.describe().contains("=")) { + if (description.contains("=")) { return TypeKind.NAMED; } diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/MockSchemas.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/MockSchemas.java index e3ea0e6..0ae2964 100644 --- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/MockSchemas.java +++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/MockSchemas.java @@ -204,6 +204,11 @@ protected void registerTypes() { /** * A builder for creating custom mock schemas. + * + *

Note: Parent schema types are not automatically inherited. + * You must explicitly add all types needed for each schema version via + * {@link #withType}. Setting a parent with {@link #withParent} only establishes + * the parent reference for schema chain traversal, not type inheritance.

*/ public static final class SchemaBuilder { diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/QuickFix.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/QuickFix.java index 5d88510..1cea34f 100644 --- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/QuickFix.java +++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/QuickFix.java @@ -175,7 +175,7 @@ public static DataFix simple( /** * Creates a fix that renames a field. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version @@ -212,7 +212,7 @@ public static DataFix renameField( /** * Creates a fix that adds a string field with a default value. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version @@ -246,7 +246,7 @@ public static DataFix addStringField( /** * Creates a fix that adds an integer field with a default value. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version @@ -279,7 +279,7 @@ public static DataFix addIntField( /** * Creates a fix that adds a boolean field with a default value. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version @@ -314,7 +314,7 @@ public static DataFix addBooleanField( /** * Creates a fix that removes a field. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version @@ -342,7 +342,7 @@ public static DataFix removeField( /** * Creates a fix that transforms a field value. * - * @param ops the DynamicOps to use + * @param ops reserved for future use (currently unused), must not be {@code null} * @param name the fix name * @param fromVersion the source version * @param toVersion the target version