Skip to content

Commit a2aff99

Browse files
authored
Merge pull request #261 from aether-framework/bugfix/124-136-138-152-164-165-187-192-203-211-final-batch
Introduce stricter state validation and refine related features
2 parents a8d4887 + 7a509b0 commit a2aff99

11 files changed

Lines changed: 136 additions & 24 deletions

File tree

aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticContext.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import de.splatgames.aether.datafixers.api.fix.DataFixerContext;
2727
import org.jetbrains.annotations.NotNull;
2828

29+
import java.util.ServiceLoader;
30+
2931
/**
3032
* A diagnostic-aware context for capturing detailed migration information.
3133
*
@@ -94,9 +96,14 @@ static DiagnosticContext create() {
9496
@NotNull
9597
static DiagnosticContext create(@NotNull final DiagnosticOptions options) {
9698
Preconditions.checkNotNull(options, "options must not be null");
97-
// Use ServiceLoader or direct instantiation
98-
// For now, we'll use direct instantiation via the core module
99-
// This will be resolved at runtime by the core implementation
99+
100+
// Discover implementation via ServiceLoader (preferred over reflection)
101+
for (final DiagnosticContextFactory factory
102+
: ServiceLoader.load(DiagnosticContextFactory.class)) {
103+
return factory.create(options);
104+
}
105+
106+
// Fallback to reflection for backward compatibility
100107
try {
101108
final Class<?> implClass = Class.forName(
102109
"de.splatgames.aether.datafixers.core.diagnostic.DiagnosticContextImpl"
@@ -107,7 +114,8 @@ static DiagnosticContext create(@NotNull final DiagnosticOptions options) {
107114
} catch (final ReflectiveOperationException e) {
108115
throw new IllegalStateException(
109116
"Failed to create DiagnosticContext. " +
110-
"Ensure aether-datafixers-core is on the classpath.",
117+
"Ensure aether-datafixers-core is on the classpath "
118+
+ "or register a DiagnosticContextFactory via ServiceLoader.",
111119
e
112120
);
113121
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) 2025 Splatgames.de Software and Contributors
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
23+
package de.splatgames.aether.datafixers.api.diagnostic;
24+
25+
import org.jetbrains.annotations.NotNull;
26+
27+
/**
28+
* Factory interface for creating {@link DiagnosticContext} instances.
29+
*
30+
* <p>Implementations are discovered via {@link java.util.ServiceLoader}. Register an
31+
* implementation by creating a file at
32+
* {@code META-INF/services/de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContextFactory}
33+
* containing the fully qualified class name of the factory implementation.</p>
34+
*
35+
* @author Erik Pförtner
36+
* @see DiagnosticContext#create(DiagnosticOptions)
37+
* @since 1.0.0
38+
*/
39+
public interface DiagnosticContextFactory {
40+
41+
/**
42+
* Creates a new {@link DiagnosticContext} with the specified options.
43+
*
44+
* @param options the diagnostic options, must not be {@code null}
45+
* @return a new diagnostic context, never {@code null}
46+
*/
47+
@NotNull
48+
DiagnosticContext create(@NotNull DiagnosticOptions options);
49+
}

aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/DynamicOps.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,21 @@ public interface DynamicOps<T> {
9999
// ==================== Empty/Null ====================
100100

101101
/**
102-
* Creates an empty value representation.
102+
* Creates an empty/null value representation.
103103
*
104-
* <p>Implementations typically return an empty map/object node, but the exact meaning is
105-
* defined by the concrete ops.</p>
104+
* <p>The returned value is the canonical "null" representation for this format.
105+
* Implementations differ in how they represent null:</p>
106+
* <ul>
107+
* <li>Jackson-based (JSON, YAML, XML): {@code NullNode.getInstance()}</li>
108+
* <li>SnakeYAML: {@code YamlNull} sentinel object</li>
109+
* <li>TOML: Note that TOML has no null representation — null values may cause
110+
* serialization errors</li>
111+
* </ul>
112+
* <p>When converting between formats via {@link #convertTo}, null representations
113+
* are normalized through this method. Use {@link #isNull(Object)} to check for null
114+
* regardless of the underlying representation.</p>
106115
*
107-
* @return an empty value
116+
* @return the canonical empty/null value for this format
108117
*/
109118
@NotNull T empty();
110119

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
import java.nio.file.StandardCopyOption;
4848
import java.time.Duration;
4949
import java.time.Instant;
50+
import java.time.ZoneOffset;
51+
import java.time.ZonedDateTime;
52+
import java.time.format.DateTimeFormatter;
5053
import java.util.List;
5154
import java.util.concurrent.Callable;
5255

@@ -688,8 +691,10 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co
688691
try {
689692
Files.writeString(tempPath, content);
690693
if (this.backup) {
694+
final String timestamp = ZonedDateTime.now(ZoneOffset.UTC)
695+
.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"));
691696
final Path backupPath = inputFile.toPath().resolveSibling(
692-
inputFile.getName() + ".bak");
697+
inputFile.getName() + ".bak." + timestamp);
693698
Files.move(inputFile.toPath(), backupPath, StandardCopyOption.REPLACE_EXISTING);
694699
}
695700
Files.move(tempPath, inputFile.toPath(), StandardCopyOption.REPLACE_EXISTING);

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@
4141
import java.util.concurrent.Callable;
4242

4343
/**
44-
* CLI command to validate data files and check if migration is needed.
44+
* CLI command to check data file versions and determine if migration is needed.
4545
*
46-
* <p>The validate command checks data files against a target schema version
47-
* without performing any modifications. This is useful for batch validation,
48-
* CI/CD pipelines, and pre-migration checks.</p>
46+
* <p>This command validates that data files contain version information and checks
47+
* whether the version is at or above the target version. It does <b>not</b> validate
48+
* schema compliance — only version numbers are checked. For full schema validation,
49+
* use the programmatic {@code SchemaValidator} API from the schema-tools module.</p>
50+
*
51+
* <p>The command is useful for batch pre-migration checks in CI/CD pipelines
52+
* to identify files that need migration without modifying them.</p>
4953
*
5054
* <h2>Usage Examples</h2>
5155
* <pre>{@code
@@ -81,7 +85,7 @@
8185
*/
8286
@Command(
8387
name = "validate",
84-
description = "Validate data files and check if migration is needed.",
88+
description = "Check data file versions and report which files need migration (does not validate schema compliance).",
8589
mixinStandardHelpOptions = true
8690
)
8791
public class ValidateCommand implements Callable<Integer> {

aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/command/MigrateCommandTest.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,13 @@ void createsBackupFiles() throws IOException {
214214
"--from", "1");
215215

216216
assertThat(exitCode).isEqualTo(0);
217-
assertThat(Files.exists(file1.resolveSibling("player1.json.bak"))).isTrue();
218-
assertThat(Files.exists(file2.resolveSibling("player2.json.bak"))).isTrue();
217+
// Backups use timestamped names: player1.json.bak.<timestamp>
218+
try (var files1 = Files.list(file1.getParent())) {
219+
assertThat(files1.anyMatch(p -> p.getFileName().toString().startsWith("player1.json.bak."))).isTrue();
220+
}
221+
try (var files2 = Files.list(file2.getParent())) {
222+
assertThat(files2.anyMatch(p -> p.getFileName().toString().startsWith("player2.json.bak."))).isTrue();
223+
}
219224
}
220225

221226
@Test

aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ public static final class BuilderImpl implements MigrationReport.Builder {
179179
private final List<RuleApplication> currentRuleApplications = new ArrayList<>();
180180
private String currentFixBeforeSnapshot;
181181

182+
// Lifecycle state
183+
private boolean migrationStarted;
184+
private boolean fixInProgress;
185+
private boolean built;
186+
182187
BuilderImpl() {
183188
}
184189

@@ -192,7 +197,10 @@ public Builder startMigration(
192197
Preconditions.checkNotNull(type, "type must not be null");
193198
Preconditions.checkNotNull(fromVersion, "fromVersion must not be null");
194199
Preconditions.checkNotNull(toVersion, "toVersion must not be null");
200+
Preconditions.checkState(!this.built, "Report already built");
201+
Preconditions.checkState(!this.migrationStarted, "Migration already started");
195202

203+
this.migrationStarted = true;
196204
this.type = type;
197205
this.fromVersion = fromVersion;
198206
this.toVersion = toVersion;
@@ -212,6 +220,11 @@ public Builder setInputSnapshot(@Nullable final String snapshot) {
212220
@NotNull
213221
public Builder startFix(@NotNull final DataFix<?> fix) {
214222
Preconditions.checkNotNull(fix, "fix must not be null");
223+
Preconditions.checkState(!this.built, "Report already built");
224+
Preconditions.checkState(this.migrationStarted, "Migration not started");
225+
Preconditions.checkState(!this.fixInProgress,
226+
"Fix already in progress: " + this.currentFixName);
227+
this.fixInProgress = true;
215228
this.currentFixName = fix.name();
216229
this.currentFixFromVersion = fix.fromVersion();
217230
this.currentFixToVersion = fix.toVersion();
@@ -244,6 +257,7 @@ public Builder endFix(
244257
@Nullable final String afterSnapshot
245258
) {
246259
Preconditions.checkNotNull(fix, "fix must not be null");
260+
Preconditions.checkState(this.fixInProgress, "No fix in progress");
247261
Preconditions.checkNotNull(duration, "duration must not be null");
248262

249263
final FixExecution execution = new FixExecution(
@@ -270,6 +284,7 @@ public Builder endFix(
270284
* to prevent stale state from corrupting subsequent fix records.
271285
*/
272286
private void resetFixState() {
287+
this.fixInProgress = false;
273288
this.currentFixName = null;
274289
this.currentFixFromVersion = null;
275290
this.currentFixToVersion = null;
@@ -304,13 +319,17 @@ public Builder setOutputSnapshot(@Nullable final String snapshot) {
304319
@Override
305320
@NotNull
306321
public MigrationReport build() {
322+
Preconditions.checkState(!this.built, "Report already built");
323+
Preconditions.checkState(!this.fixInProgress,
324+
"Cannot build report while fix is in progress: " + this.currentFixName);
307325
if (this.type == null) {
308326
throw new IllegalStateException(
309327
"Migration was not started. Call startMigration() first."
310328
);
311329
}
312330

313331
this.endTime = Instant.now();
332+
this.built = true;
314333
return new MigrationReportImpl(this);
315334
}
316335
}

aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationAnalyzer.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,13 @@ public MigrationPath analyze() {
259259
/**
260260
* Analyzes fix coverage for the migration between configured versions.
261261
*
262+
* <p><b>Known limitation:</b> Coverage analysis operates at the <i>type</i> level, not the
263+
* <i>field</i> level. While {@link CoverageGap.Reason} defines field-level reasons
264+
* ({@code FIELD_ADDED}, {@code FIELD_REMOVED}, {@code FIELD_TYPE_CHANGED}), these are
265+
* not currently populated because the analysis cannot determine which specific fields
266+
* a DataFix handles. If any fix exists for a type at a given version, all field changes
267+
* for that type are considered covered. See {@link #checkTypeDiffCoverage} for details.</p>
268+
*
262269
* @return the coverage analysis result, never {@code null}
263270
* @throws IllegalStateException if from/to versions are not set
264271
*/

aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/TypeIntrospector.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,15 @@ public static TypeKind determineKind(@NotNull final Type<?> type) {
186186

187187
// Check reference ID for other types
188188
final String refId = type.reference().getId();
189+
final String description = type.describe();
189190

190191
// Primitive types
191192
if (PRIMITIVE_TYPE_IDS.contains(refId)) {
192193
return TypeKind.PRIMITIVE;
193194
}
194195

195196
// Passthrough
196-
if ("passthrough".equals(refId) || "...".equals(type.describe())) {
197+
if ("passthrough".equals(refId) || "...".equals(description)) {
197198
return TypeKind.PASSTHROUGH;
198199
}
199200

@@ -218,7 +219,7 @@ public static TypeKind determineKind(@NotNull final Type<?> type) {
218219
}
219220

220221
// Named type (has "=" in description)
221-
if (type.describe().contains("=")) {
222+
if (description.contains("=")) {
222223
return TypeKind.NAMED;
223224
}
224225

aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/factory/MockSchemas.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ protected void registerTypes() {
204204

205205
/**
206206
* A builder for creating custom mock schemas.
207+
*
208+
* <p><b>Note:</b> Parent schema types are <b>not</b> automatically inherited.
209+
* You must explicitly add all types needed for each schema version via
210+
* {@link #withType}. Setting a parent with {@link #withParent} only establishes
211+
* the parent reference for schema chain traversal, not type inheritance.</p>
207212
*/
208213
public static final class SchemaBuilder {
209214

0 commit comments

Comments
 (0)