diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eefb80d..2e41420 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,11 +13,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 24
uses: actions/setup-java@v4
with:
- distribution: temurin
- java-version: '21'
+ distribution: oracle
+ java-version: '24'
cache: 'maven'
- name: Build and verify
@@ -39,7 +39,7 @@ jobs:
for k in totals: totals[k]+=int(r.get(k,'0'))
except Exception:
pass
- exp_tests=611
+ exp_tests=1354
exp_skipped=0
if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped:
print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}")
diff --git a/json-java21-jtd-codegen/pom.xml b/json-java21-jtd-codegen/pom.xml
new file mode 100644
index 0000000..3185af8
--- /dev/null
+++ b/json-java21-jtd-codegen/pom.xml
@@ -0,0 +1,92 @@
+
+
+ 4.0.0
+
+
+ io.github.simbo1905.json
+ parent
+ 0.1.9
+
+
+ java.util.json.jtd.codegen
+ jar
+ java.util.json Java21 Backport JTD Codegen
+ https://simbo1905.github.io/java.util.json.Java21/
+
+ scm:git:https://github.com/simbo1905/java.util.json.Java21.git
+ scm:git:git@github.com:simbo1905/java.util.json.Java21.git
+ https://github.com/simbo1905/java.util.json.Java21
+ HEAD
+
+ Bytecode-generated JTD validators using the JDK 24+ ClassFile API.
+ Generates Java 21 compatible classfiles for hot-path validation.
+ Optional dependency: falls back to the interpreter path when absent.
+
+
+ UTF-8
+ 24
+
+
+
+
+ io.github.simbo1905.json
+ java.util.json
+ ${project.version}
+
+
+ io.github.simbo1905.json
+ java.util.json.jtd
+ ${project.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 24
+
+ -Xlint:all
+ -Xdiags:verbose
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+ 24
+ none
+
+
+
+
+
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java
new file mode 100644
index 0000000..13d147a
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java
@@ -0,0 +1,74 @@
+package json.java21.jtd.codegen;
+
+import java.lang.constant.ClassDesc;
+import java.lang.constant.ConstantDescs;
+import java.lang.constant.MethodTypeDesc;
+
+/// Shared class descriptors and method type descriptors for bytecode emission.
+///
+/// All fields are compile-time constants referencing the types the generated
+/// classfiles interact with at runtime (JSON API, validation result types, JDK stdlib).
+final class Descriptors {
+
+ private Descriptors() {}
+
+ // -- JDK types --
+ static final ClassDesc CD_Object = ConstantDescs.CD_Object;
+ static final ClassDesc CD_String = ConstantDescs.CD_String;
+ static final ClassDesc CD_Math = ClassDesc.of("java.lang.Math");
+ static final ClassDesc CD_CharSequence = ClassDesc.of("java.lang.CharSequence");
+ static final ClassDesc CD_OffsetDateTime = ClassDesc.of("java.time.OffsetDateTime");
+ static final ClassDesc CD_DateTimeFormatter = ClassDesc.of("java.time.format.DateTimeFormatter");
+ static final ClassDesc CD_Pattern = ClassDesc.of("java.util.regex.Pattern");
+ static final ClassDesc CD_Matcher = ClassDesc.of("java.util.regex.Matcher");
+
+ // -- Collections --
+ static final ClassDesc CD_ArrayList = ClassDesc.of("java.util.ArrayList");
+ static final ClassDesc CD_List = ClassDesc.of("java.util.List");
+ static final ClassDesc CD_Map = ClassDesc.of("java.util.Map");
+ static final ClassDesc CD_MapEntry = ClassDesc.of("java.util.Map$Entry");
+ static final ClassDesc CD_Set = ClassDesc.of("java.util.Set");
+ static final ClassDesc CD_Iterator = ClassDesc.of("java.util.Iterator");
+
+ // -- JSON API types --
+ static final ClassDesc CD_JsonValue = ClassDesc.of("jdk.sandbox.java.util.json.JsonValue");
+ static final ClassDesc CD_JsonObject = ClassDesc.of("jdk.sandbox.java.util.json.JsonObject");
+ static final ClassDesc CD_JsonArray = ClassDesc.of("jdk.sandbox.java.util.json.JsonArray");
+ static final ClassDesc CD_JsonString = ClassDesc.of("jdk.sandbox.java.util.json.JsonString");
+ static final ClassDesc CD_JsonNumber = ClassDesc.of("jdk.sandbox.java.util.json.JsonNumber");
+ static final ClassDesc CD_JsonBoolean = ClassDesc.of("jdk.sandbox.java.util.json.JsonBoolean");
+ static final ClassDesc CD_JsonNull = ClassDesc.of("jdk.sandbox.java.util.json.JsonNull");
+
+ // -- Validation result types --
+ static final ClassDesc CD_JtdValidationError = ClassDesc.of("json.java21.jtd.JtdValidationError");
+ static final ClassDesc CD_JtdValidationResult = ClassDesc.of("json.java21.jtd.JtdValidationResult");
+ static final ClassDesc CD_JtdValidator = ClassDesc.of("json.java21.jtd.JtdValidator");
+
+ // -- Common method type descriptors --
+ static final MethodTypeDesc MTD_String = MethodTypeDesc.of(CD_String);
+ static final MethodTypeDesc MTD_boolean = MethodTypeDesc.of(ConstantDescs.CD_boolean);
+ static final MethodTypeDesc MTD_double = MethodTypeDesc.of(ConstantDescs.CD_double);
+ static final MethodTypeDesc MTD_long = MethodTypeDesc.of(ConstantDescs.CD_long);
+ static final MethodTypeDesc MTD_int = MethodTypeDesc.of(ConstantDescs.CD_int);
+ static final MethodTypeDesc MTD_boolean_Object = MethodTypeDesc.of(ConstantDescs.CD_boolean, CD_Object);
+ static final MethodTypeDesc MTD_Object_Object = MethodTypeDesc.of(CD_Object, CD_Object);
+ static final MethodTypeDesc MTD_Object_int = MethodTypeDesc.of(CD_Object, ConstantDescs.CD_int);
+ static final MethodTypeDesc MTD_boolean_CharSequence = MethodTypeDesc.of(ConstantDescs.CD_boolean, CD_CharSequence);
+ static final MethodTypeDesc MTD_String_String = MethodTypeDesc.of(CD_String, CD_String);
+ static final MethodTypeDesc MTD_String_int = MethodTypeDesc.of(CD_String, ConstantDescs.CD_int);
+ static final MethodTypeDesc MTD_String_CharSeq_CharSeq = MethodTypeDesc.of(CD_String, CD_CharSequence, CD_CharSequence);
+ static final MethodTypeDesc MTD_Map = MethodTypeDesc.of(CD_Map);
+ static final MethodTypeDesc MTD_List = MethodTypeDesc.of(CD_List);
+ static final MethodTypeDesc MTD_Set = MethodTypeDesc.of(CD_Set);
+ static final MethodTypeDesc MTD_Iterator = MethodTypeDesc.of(CD_Iterator);
+ static final MethodTypeDesc MTD_Object = MethodTypeDesc.of(CD_Object);
+ static final MethodTypeDesc MTD_double_double = MethodTypeDesc.of(ConstantDescs.CD_double, ConstantDescs.CD_double);
+ static final MethodTypeDesc MTD_Pattern_String = MethodTypeDesc.of(CD_Pattern, CD_String);
+ static final MethodTypeDesc MTD_Matcher_CharSequence = MethodTypeDesc.of(CD_Matcher, CD_CharSequence);
+ static final MethodTypeDesc MTD_OffsetDateTime_CharSeq_DTF = MethodTypeDesc.of(CD_OffsetDateTime, CD_CharSequence, CD_DateTimeFormatter);
+ static final MethodTypeDesc MTD_void_String_String = MethodTypeDesc.of(ConstantDescs.CD_void, CD_String, CD_String);
+ static final MethodTypeDesc MTD_JtdValidationResult = MethodTypeDesc.of(CD_JtdValidationResult);
+ static final MethodTypeDesc MTD_JtdValidationResult_List = MethodTypeDesc.of(CD_JtdValidationResult, CD_List);
+ static final MethodTypeDesc MTD_JtdValidationResult_JsonValue = MethodTypeDesc.of(CD_JtdValidationResult, CD_JsonValue);
+ static final MethodTypeDesc MTD_void_String = MethodTypeDesc.of(ConstantDescs.CD_void, CD_String);
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java
new file mode 100644
index 0000000..f49de7b
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java
@@ -0,0 +1,193 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+import java.lang.constant.ConstantDescs;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Discriminator schema (tagged union).
+final class EmitDiscriminator {
+
+ private EmitDiscriminator() {}
+
+ static void emit(CodeBuilder cob, JtdSchema.DiscriminatorSchema d,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var end = cob.newLabel();
+
+ // Step 1: must be object
+ var step1Fail = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(step1Fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ // Step 2: tag must exist
+ var step2Fail = cob.newLabel();
+ cob.aload(mapSlot);
+ cob.ldc(d.discriminator());
+ cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object);
+ cob.ifeq(step2Fail);
+
+ cob.aload(mapSlot);
+ cob.ldc(d.discriminator());
+ cob.invokeinterface(CD_Map, "get", MTD_Object_Object);
+ cob.checkcast(CD_JsonValue);
+ int tagValSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagValSlot);
+
+ // Step 3: tag must be string
+ var step3Fail = cob.newLabel();
+ cob.aload(tagValSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifeq(step3Fail);
+
+ cob.aload(tagValSlot);
+ cob.checkcast(CD_JsonString);
+ cob.invokeinterface(CD_JsonString, "string", MTD_String);
+ int tagStrSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagStrSlot);
+
+ // Step 4: dispatch to variants
+ for (final var entry : d.mapping().entrySet()) {
+ final var tagValue = entry.getKey();
+ final var variantSchema = entry.getValue();
+ var nextVariant = cob.newLabel();
+
+ cob.aload(tagStrSlot);
+ cob.ldc(tagValue);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifeq(nextVariant);
+
+ if (variantSchema instanceof JtdSchema.PropertiesSchema props) {
+ EmitProperties.emit(cob, props, instSlot, errSlot, instPath,
+ schemaPath + "/mapping/" + tagValue, d.discriminator());
+ } else {
+ EmitNode.emit(cob, variantSchema, instSlot, errSlot, instPath,
+ schemaPath + "/mapping/" + tagValue);
+ }
+ cob.goto_(end);
+
+ cob.labelBinding(nextVariant);
+ }
+
+ // Step 5: tag not in mapping
+ EmitError.addError(cob, errSlot,
+ instPath + "/" + d.discriminator(), schemaPath + "/mapping");
+ cob.goto_(end);
+
+ // Error paths
+ cob.labelBinding(step1Fail);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/discriminator");
+ cob.goto_(end);
+
+ cob.labelBinding(step2Fail);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/discriminator");
+ cob.goto_(end);
+
+ cob.labelBinding(step3Fail);
+ EmitError.addError(cob, errSlot,
+ instPath + "/" + d.discriminator(), schemaPath + "/discriminator");
+
+ cob.labelBinding(end);
+ }
+
+ /// Dynamic-path variant: parent instancePath from local variable.
+ static void emitDynamic(CodeBuilder cob, JtdSchema.DiscriminatorSchema d,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var end = cob.newLabel();
+
+ var step1Fail = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(step1Fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ var step2Fail = cob.newLabel();
+ cob.aload(mapSlot);
+ cob.ldc(d.discriminator());
+ cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object);
+ cob.ifeq(step2Fail);
+
+ cob.aload(mapSlot);
+ cob.ldc(d.discriminator());
+ cob.invokeinterface(CD_Map, "get", MTD_Object_Object);
+ cob.checkcast(CD_JsonValue);
+ int tagValSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagValSlot);
+
+ var step3Fail = cob.newLabel();
+ cob.aload(tagValSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifeq(step3Fail);
+
+ cob.aload(tagValSlot);
+ cob.checkcast(CD_JsonString);
+ cob.invokeinterface(CD_JsonString, "string", MTD_String);
+ int tagStrSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagStrSlot);
+
+ for (final var entry : d.mapping().entrySet()) {
+ final var tagValue = entry.getKey();
+ final var variantSchema = entry.getValue();
+ var nextVariant = cob.newLabel();
+
+ cob.aload(tagStrSlot);
+ cob.ldc(tagValue);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifeq(nextVariant);
+
+ if (variantSchema instanceof JtdSchema.PropertiesSchema props) {
+ EmitProperties.emitDynamic(cob, props, instSlot, errSlot, pathSlot,
+ schemaPath + "/mapping/" + tagValue, d.discriminator());
+ } else {
+ EmitNode.emitDynamic(cob, variantSchema, instSlot, errSlot, pathSlot,
+ schemaPath + "/mapping/" + tagValue);
+ }
+ cob.goto_(end);
+
+ cob.labelBinding(nextVariant);
+ }
+
+ // tag not in mapping: error at pathSlot + "/" + discriminator
+ cob.aload(pathSlot);
+ cob.ldc("/" + d.discriminator());
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int tagPathSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagPathSlot);
+ EmitError.addErrorDynamic(cob, errSlot, tagPathSlot, schemaPath + "/mapping");
+ cob.goto_(end);
+
+ cob.labelBinding(step1Fail);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/discriminator");
+ cob.goto_(end);
+
+ cob.labelBinding(step2Fail);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/discriminator");
+ cob.goto_(end);
+
+ cob.labelBinding(step3Fail);
+ cob.aload(pathSlot);
+ cob.ldc("/" + d.discriminator());
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int tagPath2Slot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(tagPath2Slot);
+ EmitError.addErrorDynamic(cob, errSlot, tagPath2Slot, schemaPath + "/discriminator");
+
+ cob.labelBinding(end);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java
new file mode 100644
index 0000000..9ded40c
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java
@@ -0,0 +1,149 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Elements schema (array validation).
+final class EmitElements {
+
+ private EmitElements() {}
+
+ static void emit(CodeBuilder cob, JtdSchema.ElementsSchema e,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonArray);
+ cob.ifeq(fail);
+
+ emitLoop(cob, e, instSlot, errSlot, instPath + "/", schemaPath + "/elements");
+
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/elements");
+ cob.labelBinding(ok);
+ }
+
+ /// Dynamic-path variant: parent instancePath comes from a local variable.
+ static void emitDynamic(CodeBuilder cob, JtdSchema.ElementsSchema e,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonArray);
+ cob.ifeq(fail);
+
+ // Build prefix: parentPath + "/"
+ cob.aload(pathSlot);
+ cob.ldc("/");
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(prefixSlot);
+
+ emitLoopDynamic(cob, e, instSlot, errSlot, prefixSlot, schemaPath + "/elements");
+
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/elements");
+ cob.labelBinding(ok);
+ }
+
+ /// Shared loop logic: iterates array elements and validates each.
+ /// `prefix` is a compile-time string like "instPath/".
+ private static void emitLoop(CodeBuilder cob, JtdSchema.ElementsSchema e,
+ int instSlot, int errSlot,
+ String prefix, String childSchemaPath) {
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonArray);
+ cob.invokeinterface(CD_JsonArray, "elements", MTD_List);
+ int listSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(listSlot);
+
+ cob.aload(listSlot);
+ cob.invokeinterface(CD_List, "size", MTD_int);
+ int sizeSlot = cob.allocateLocal(TypeKind.INT);
+ cob.istore(sizeSlot);
+
+ int iSlot = cob.allocateLocal(TypeKind.INT);
+ cob.iconst_0();
+ cob.istore(iSlot);
+
+ cob.ldc(prefix);
+ int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(prefixSlot);
+
+ var loopStart = cob.newLabel();
+ var loopEnd = cob.newLabel();
+
+ cob.labelBinding(loopStart);
+ cob.iload(iSlot);
+ cob.iload(sizeSlot);
+ cob.if_icmpge(loopEnd);
+
+ cob.aload(listSlot);
+ cob.iload(iSlot);
+ cob.invokeinterface(CD_List, "get", MTD_Object_int);
+ cob.checkcast(CD_JsonValue);
+ int elemSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(elemSlot);
+
+ EmitNode.emitDeferredIdx(cob, e.elements(), elemSlot, errSlot, prefixSlot, iSlot, childSchemaPath);
+
+ cob.iinc(iSlot, 1);
+ cob.goto_(loopStart);
+
+ cob.labelBinding(loopEnd);
+ }
+
+ /// Loop with dynamic prefix: prefix comes from a local variable.
+ private static void emitLoopDynamic(CodeBuilder cob, JtdSchema.ElementsSchema e,
+ int instSlot, int errSlot,
+ int prefixSlot, String childSchemaPath) {
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonArray);
+ cob.invokeinterface(CD_JsonArray, "elements", MTD_List);
+ int listSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(listSlot);
+
+ cob.aload(listSlot);
+ cob.invokeinterface(CD_List, "size", MTD_int);
+ int sizeSlot = cob.allocateLocal(TypeKind.INT);
+ cob.istore(sizeSlot);
+
+ int iSlot = cob.allocateLocal(TypeKind.INT);
+ cob.iconst_0();
+ cob.istore(iSlot);
+
+ var loopStart = cob.newLabel();
+ var loopEnd = cob.newLabel();
+
+ cob.labelBinding(loopStart);
+ cob.iload(iSlot);
+ cob.iload(sizeSlot);
+ cob.if_icmpge(loopEnd);
+
+ cob.aload(listSlot);
+ cob.iload(iSlot);
+ cob.invokeinterface(CD_List, "get", MTD_Object_int);
+ cob.checkcast(CD_JsonValue);
+ int elemSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(elemSlot);
+
+ EmitNode.emitDeferredIdx(cob, e.elements(), elemSlot, errSlot, prefixSlot, iSlot, childSchemaPath);
+
+ cob.iinc(iSlot, 1);
+ cob.goto_(loopStart);
+
+ cob.labelBinding(loopEnd);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java
new file mode 100644
index 0000000..9125a51
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java
@@ -0,0 +1,75 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Enum schema checks.
+///
+/// Checks: instance is a JsonString whose value is in the enum's value set.
+final class EmitEnum {
+
+ private EmitEnum() {}
+
+ static void emit(CodeBuilder cob, JtdSchema.EnumSchema e,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ emitEnumCore(cob, e, instSlot, errSlot, schemaPath,
+ (c, es, sp) -> EmitError.addError(c, es, instPath, sp));
+ }
+
+ static void emitDynamic(CodeBuilder cob, JtdSchema.EnumSchema e,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ emitEnumCore(cob, e, instSlot, errSlot, schemaPath,
+ (c, es, sp) -> EmitError.addErrorDynamic(c, es, pathSlot, sp));
+ }
+
+ static void emitDeferredStr(CodeBuilder cob, JtdSchema.EnumSchema e,
+ int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ emitEnumCore(cob, e, instSlot, errSlot, schemaPath,
+ (c, es, sp) -> EmitError.addErrorDeferred(c, es, parentPathSlot, segmentSlot, sp));
+ }
+
+ static void emitDeferredIdx(CodeBuilder cob, JtdSchema.EnumSchema e,
+ int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ emitEnumCore(cob, e, instSlot, errSlot, schemaPath,
+ (c, es, sp) -> EmitError.addErrorDeferredIdx(c, es, prefixSlot, indexSlot, sp));
+ }
+
+ private static void emitEnumCore(CodeBuilder cob, JtdSchema.EnumSchema e,
+ int instSlot, int errSlot,
+ String schemaPath,
+ EmitType.ErrorEmitter onError) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifeq(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonString);
+ cob.invokeinterface(CD_JsonString, "string", MTD_String);
+ int strSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(strSlot);
+
+ for (final var val : e.values()) {
+ cob.aload(strSlot);
+ cob.ldc(val);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifne(ok);
+ }
+
+ cob.labelBinding(fail);
+ onError.emit(cob, errSlot, schemaPath + "/enum");
+ cob.labelBinding(ok);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java
new file mode 100644
index 0000000..d781182
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java
@@ -0,0 +1,81 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.constant.ConstantDescs;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode to add a [JtdValidationError] to the errors list.
+///
+/// Four variants:
+/// - [#addError]: both instancePath and schemaPath are compile-time string constants.
+/// - [#addErrorDynamic]: instancePath comes from a local variable slot (runtime string).
+/// - [#addErrorDeferred]: instancePath is `parentPath.concat(segment)`, only materialized at error time.
+/// - [#addErrorDeferredIdx]: instancePath is `prefix.concat(String.valueOf(index))`, only materialized at error time.
+final class EmitError {
+
+ private EmitError() {}
+
+ /// Emit: errors.add(new JtdValidationError(instPath, schemaPath))
+ /// Both paths are compile-time constants baked into the classfile.
+ static void addError(CodeBuilder cob, int errSlot,
+ String instPath, String schemaPath) {
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.ldc(instPath);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+ }
+
+ /// Emit: errors.add(new JtdValidationError(localPathSlot, schemaPath))
+ /// instancePath comes from a local variable, schemaPath is a constant.
+ static void addErrorDynamic(CodeBuilder cob, int errSlot,
+ int pathSlot, String schemaPath) {
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.aload(pathSlot);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+ }
+
+ /// Deferred string-segment variant: instancePath = parentPath.concat(segment).
+ /// Both parentPath and segment are local string variables; concat only happens here.
+ static void addErrorDeferred(CodeBuilder cob, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.aload(parentPathSlot);
+ cob.aload(segmentSlot);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+ }
+
+ /// Deferred index-segment variant: instancePath = prefix.concat(String.valueOf(index)).
+ /// prefix is a local string variable, index is a local int; only materialized here.
+ static void addErrorDeferredIdx(CodeBuilder cob, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.aload(prefixSlot);
+ cob.iload(indexSlot);
+ cob.invokestatic(CD_String, "valueOf", MTD_String_int);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java
new file mode 100644
index 0000000..ca8da61
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java
@@ -0,0 +1,226 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+import java.lang.constant.ConstantDescs;
+import java.util.logging.Logger;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Dispatches AST node emission to the appropriate emitter.
+///
+/// This is the central routing point: each [JtdSchema] variant maps to
+/// a specific emitter class. Static-path variants bake the instance path
+/// as a string constant; dynamic-path variants read it from a local variable.
+final class EmitNode {
+
+ private static final Logger LOG = Logger.getLogger(EmitNode.class.getName());
+
+ private EmitNode() {}
+
+ /// Emit bytecode for an AST node where instancePath is a compile-time constant.
+ static void emit(CodeBuilder cob, JtdSchema node,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ switch (node) {
+ case JtdSchema.EmptySchema e -> { /* no check */ }
+ case JtdSchema.NullableSchema n ->
+ emitNullable(cob, n, instSlot, errSlot, instPath, schemaPath);
+ case JtdSchema.RefSchema r ->
+ emit(cob, r.target(), instSlot, errSlot, instPath, "/definitions/" + r.ref());
+ case JtdSchema.TypeSchema t ->
+ emitType(cob, t, instSlot, errSlot, instPath, schemaPath);
+ case JtdSchema.EnumSchema e ->
+ EmitEnum.emit(cob, e, instSlot, errSlot, instPath, schemaPath);
+ case JtdSchema.ElementsSchema e ->
+ EmitElements.emit(cob, e, instSlot, errSlot, instPath, schemaPath);
+ case JtdSchema.PropertiesSchema p ->
+ EmitProperties.emit(cob, p, instSlot, errSlot, instPath, schemaPath, null);
+ case JtdSchema.ValuesSchema v ->
+ EmitValues.emit(cob, v, instSlot, errSlot, instPath, schemaPath);
+ case JtdSchema.DiscriminatorSchema d ->
+ EmitDiscriminator.emit(cob, d, instSlot, errSlot, instPath, schemaPath);
+ }
+ }
+
+ /// Emit bytecode for an AST node where instancePath is in a local variable.
+ static void emitDynamic(CodeBuilder cob, JtdSchema node,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ switch (node) {
+ case JtdSchema.EmptySchema e -> { /* no check */ }
+ case JtdSchema.NullableSchema n -> {
+ var afterNull = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNull);
+ cob.ifne(afterNull);
+ emitDynamic(cob, n.wrapped(), instSlot, errSlot, pathSlot, schemaPath);
+ cob.labelBinding(afterNull);
+ }
+ case JtdSchema.RefSchema r ->
+ emitDynamic(cob, r.target(), instSlot, errSlot, pathSlot, "/definitions/" + r.ref());
+ case JtdSchema.TypeSchema t ->
+ emitTypeDynamic(cob, t, instSlot, errSlot, pathSlot, schemaPath);
+ case JtdSchema.EnumSchema e ->
+ EmitEnum.emitDynamic(cob, e, instSlot, errSlot, pathSlot, schemaPath);
+ case JtdSchema.ElementsSchema e ->
+ EmitElements.emitDynamic(cob, e, instSlot, errSlot, pathSlot, schemaPath);
+ case JtdSchema.PropertiesSchema p ->
+ EmitProperties.emitDynamic(cob, p, instSlot, errSlot, pathSlot, schemaPath, null);
+ case JtdSchema.ValuesSchema v ->
+ EmitValues.emitDynamic(cob, v, instSlot, errSlot, pathSlot, schemaPath);
+ case JtdSchema.DiscriminatorSchema d ->
+ EmitDiscriminator.emitDynamic(cob, d, instSlot, errSlot, pathSlot, schemaPath);
+ }
+ }
+
+ private static void emitNullable(CodeBuilder cob, JtdSchema.NullableSchema n,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var afterNull = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNull);
+ cob.ifne(afterNull);
+ emit(cob, n.wrapped(), instSlot, errSlot, instPath, schemaPath);
+ cob.labelBinding(afterNull);
+ }
+
+ private static void emitType(CodeBuilder cob, JtdSchema.TypeSchema t,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ switch (t.type()) {
+ case "boolean" -> EmitType.emitBoolean(cob, instSlot, errSlot, instPath, schemaPath);
+ case "string" -> EmitType.emitString(cob, instSlot, errSlot, instPath, schemaPath);
+ case "float32", "float64" -> EmitType.emitFloat(cob, instSlot, errSlot, instPath, schemaPath);
+ case "timestamp" -> EmitType.emitTimestamp(cob, instSlot, errSlot, instPath, schemaPath);
+ default -> EmitType.emitInt(cob, t.type(), instSlot, errSlot, instPath, schemaPath);
+ }
+ }
+
+ private static void emitTypeDynamic(CodeBuilder cob, JtdSchema.TypeSchema t,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ switch (t.type()) {
+ case "boolean" -> EmitType.emitBooleanDynamic(cob, instSlot, errSlot, pathSlot, schemaPath);
+ case "string" -> EmitType.emitStringDynamic(cob, instSlot, errSlot, pathSlot, schemaPath);
+ case "float32", "float64" -> EmitType.emitFloatDynamic(cob, instSlot, errSlot, pathSlot, schemaPath);
+ default -> EmitType.emitIntDynamic(cob, t.type(), instSlot, errSlot, pathSlot, schemaPath);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Deferred-path dispatchers: concat only on error for leaf schemas.
+ // Containers materialize the path once, then delegate to emitDynamic.
+ // ------------------------------------------------------------------
+
+ /// Deferred string-segment variant: parentPathSlot + segmentStringSlot.
+ static void emitDeferredStr(CodeBuilder cob, JtdSchema node,
+ int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ switch (node) {
+ case JtdSchema.EmptySchema _ -> { /* no check */ }
+ case JtdSchema.NullableSchema n -> {
+ var afterNull = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNull);
+ cob.ifne(afterNull);
+ emitDeferredStr(cob, n.wrapped(), instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ cob.labelBinding(afterNull);
+ }
+ case JtdSchema.RefSchema r ->
+ emitDeferredStr(cob, r.target(), instSlot, errSlot, parentPathSlot, segmentSlot, "/definitions/" + r.ref());
+ case JtdSchema.TypeSchema t ->
+ emitTypeDeferredStr(cob, t, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ case JtdSchema.EnumSchema e ->
+ EmitEnum.emitDeferredStr(cob, e, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ case JtdSchema.ElementsSchema _,
+ JtdSchema.PropertiesSchema _,
+ JtdSchema.ValuesSchema _,
+ JtdSchema.DiscriminatorSchema _ -> {
+ int pathSlot = materializeStr(cob, parentPathSlot, segmentSlot);
+ emitDynamic(cob, node, instSlot, errSlot, pathSlot, schemaPath);
+ }
+ }
+ }
+
+ /// Deferred index-segment variant: prefixSlot + indexIntSlot.
+ static void emitDeferredIdx(CodeBuilder cob, JtdSchema node,
+ int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ switch (node) {
+ case JtdSchema.EmptySchema _ -> { /* no check */ }
+ case JtdSchema.NullableSchema n -> {
+ var afterNull = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNull);
+ cob.ifne(afterNull);
+ emitDeferredIdx(cob, n.wrapped(), instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ cob.labelBinding(afterNull);
+ }
+ case JtdSchema.RefSchema r ->
+ emitDeferredIdx(cob, r.target(), instSlot, errSlot, prefixSlot, indexSlot, "/definitions/" + r.ref());
+ case JtdSchema.TypeSchema t ->
+ emitTypeDeferredIdx(cob, t, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ case JtdSchema.EnumSchema e ->
+ EmitEnum.emitDeferredIdx(cob, e, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ case JtdSchema.ElementsSchema _,
+ JtdSchema.PropertiesSchema _,
+ JtdSchema.ValuesSchema _,
+ JtdSchema.DiscriminatorSchema _ -> {
+ int pathSlot = materializeIdx(cob, prefixSlot, indexSlot);
+ emitDynamic(cob, node, instSlot, errSlot, pathSlot, schemaPath);
+ }
+ }
+ }
+
+ private static void emitTypeDeferredStr(CodeBuilder cob, JtdSchema.TypeSchema t,
+ int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ switch (t.type()) {
+ case "boolean" -> EmitType.emitBooleanDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ case "string" -> EmitType.emitStringDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ case "float32", "float64" -> EmitType.emitFloatDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ case "timestamp" -> EmitType.emitTimestampDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ default -> EmitType.emitIntDeferredStr(cob, t.type(), instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath);
+ }
+ }
+
+ private static void emitTypeDeferredIdx(CodeBuilder cob, JtdSchema.TypeSchema t,
+ int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ switch (t.type()) {
+ case "boolean" -> EmitType.emitBooleanDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ case "string" -> EmitType.emitStringDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ case "float32", "float64" -> EmitType.emitFloatDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ case "timestamp" -> EmitType.emitTimestampDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ default -> EmitType.emitIntDeferredIdx(cob, t.type(), instSlot, errSlot, prefixSlot, indexSlot, schemaPath);
+ }
+ }
+
+ /// Materialize: parentPath.concat(segment) → new local slot.
+ private static int materializeStr(CodeBuilder cob, int parentPathSlot, int segmentSlot) {
+ cob.aload(parentPathSlot);
+ cob.aload(segmentSlot);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int slot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(slot);
+ return slot;
+ }
+
+ /// Materialize: prefix.concat(String.valueOf(index)) → new local slot.
+ private static int materializeIdx(CodeBuilder cob, int prefixSlot, int indexSlot) {
+ cob.aload(prefixSlot);
+ cob.iload(indexSlot);
+ cob.invokestatic(CD_String, "valueOf", MTD_String_int);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int slot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(slot);
+ return slot;
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java
new file mode 100644
index 0000000..44e8e05
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java
@@ -0,0 +1,297 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.Label;
+import java.lang.classfile.TypeKind;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Properties schema (object validation).
+///
+/// Two variants:
+/// - [#emit]: instance path is a compile-time constant.
+/// - [#emitDynamic]: instance path is in a local variable (used inside loops).
+final class EmitProperties {
+
+ private EmitProperties() {}
+
+ static void emit(CodeBuilder cob, JtdSchema.PropertiesSchema p,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath,
+ String discriminatorTag) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ for (final var entry : p.properties().entrySet()) {
+ final var key = entry.getKey();
+ var present = cob.newLabel();
+ cob.aload(mapSlot);
+ cob.ldc(key);
+ cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object);
+ cob.ifne(present);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/properties/" + key);
+ cob.labelBinding(present);
+ }
+
+ if (!p.additionalProperties()) {
+ emitAdditionalCheck(cob, p, mapSlot, errSlot, instPath, schemaPath, discriminatorTag);
+ }
+
+ for (final var entry : p.properties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discriminatorTag)) continue;
+ emitPropertyChild(cob, entry.getValue(), mapSlot, errSlot, key,
+ instPath + "/" + key, schemaPath + "/properties/" + key);
+ }
+
+ for (final var entry : p.optionalProperties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discriminatorTag)) continue;
+ emitPropertyChild(cob, entry.getValue(), mapSlot, errSlot, key,
+ instPath + "/" + key, schemaPath + "/optionalProperties/" + key);
+ }
+
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ final var guardSuffix = p.properties().isEmpty() ? "/optionalProperties" : "/properties";
+ EmitError.addError(cob, errSlot, instPath, schemaPath + guardSuffix);
+ cob.labelBinding(ok);
+ }
+
+ /// Dynamic-path variant: parent instancePath comes from a local variable.
+ static void emitDynamic(CodeBuilder cob, JtdSchema.PropertiesSchema p,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath,
+ String discriminatorTag) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ // Missing required keys: error path = pathSlot + "/properties/" + key
+ for (final var entry : p.properties().entrySet()) {
+ final var key = entry.getKey();
+ var present = cob.newLabel();
+ cob.aload(mapSlot);
+ cob.ldc(key);
+ cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object);
+ cob.ifne(present);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/properties/" + key);
+ cob.labelBinding(present);
+ }
+
+ if (!p.additionalProperties()) {
+ emitAdditionalCheckDynamic(cob, p, mapSlot, errSlot, pathSlot, schemaPath, discriminatorTag);
+ }
+
+ // Child property values: build child path = pathSlot + "/" + key
+ for (final var entry : p.properties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discriminatorTag)) continue;
+ emitPropertyChildDynamic(cob, entry.getValue(), mapSlot, errSlot, pathSlot, key,
+ schemaPath + "/properties/" + key);
+ }
+
+ for (final var entry : p.optionalProperties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discriminatorTag)) continue;
+ emitPropertyChildDynamic(cob, entry.getValue(), mapSlot, errSlot, pathSlot, key,
+ schemaPath + "/optionalProperties/" + key);
+ }
+
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ final var dynGuardSuffix = p.properties().isEmpty() ? "/optionalProperties" : "/properties";
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + dynGuardSuffix);
+ cob.labelBinding(ok);
+ }
+
+ private static void emitPropertyChild(CodeBuilder cob, JtdSchema childSchema,
+ int mapSlot, int errSlot, String key,
+ String childInstPath, String childSchemaPath) {
+ var absent = cob.newLabel();
+ var after = cob.newLabel();
+
+ cob.aload(mapSlot);
+ cob.ldc(key);
+ cob.invokeinterface(CD_Map, "get", MTD_Object_Object);
+ cob.dup();
+ cob.ifnull(absent);
+ cob.checkcast(CD_JsonValue);
+ int childSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(childSlot);
+ EmitNode.emit(cob, childSchema, childSlot, errSlot, childInstPath, childSchemaPath);
+ cob.goto_(after);
+
+ cob.labelBinding(absent);
+ cob.pop();
+ cob.labelBinding(after);
+ }
+
+ /// Dynamic child: uses deferred path for leaf schemas, materialized path for containers.
+ private static void emitPropertyChildDynamic(CodeBuilder cob, JtdSchema childSchema,
+ int mapSlot, int errSlot,
+ int parentPathSlot, String key,
+ String childSchemaPath) {
+ var absent = cob.newLabel();
+ var after = cob.newLabel();
+
+ cob.aload(mapSlot);
+ cob.ldc(key);
+ cob.invokeinterface(CD_Map, "get", MTD_Object_Object);
+ cob.dup();
+ cob.ifnull(absent);
+ cob.checkcast(CD_JsonValue);
+ int childSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(childSlot);
+
+ cob.ldc("/" + key);
+ int segmentSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(segmentSlot);
+
+ EmitNode.emitDeferredStr(cob, childSchema, childSlot, errSlot,
+ parentPathSlot, segmentSlot, childSchemaPath);
+ cob.goto_(after);
+
+ cob.labelBinding(absent);
+ cob.pop();
+ cob.labelBinding(after);
+ }
+
+ private static void emitAdditionalCheck(CodeBuilder cob, JtdSchema.PropertiesSchema p,
+ int mapSlot, int errSlot,
+ String instPath, String schemaPath,
+ String discriminatorTag) {
+ cob.aload(mapSlot);
+ cob.invokeinterface(CD_Map, "keySet", MTD_Set);
+ cob.invokeinterface(CD_Set, "iterator", MTD_Iterator);
+ int iterSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(iterSlot);
+
+ var loopStart = cob.newLabel();
+ var loopEnd = cob.newLabel();
+
+ cob.labelBinding(loopStart);
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean);
+ cob.ifeq(loopEnd);
+
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "next", MTD_Object);
+ cob.checkcast(CD_String);
+ int keySlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(keySlot);
+
+ var known = cob.newLabel();
+ emitKnownKeyChecks(cob, p, keySlot, known, discriminatorTag);
+
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.ldc(instPath + "/");
+ cob.aload(keySlot);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, "", MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+
+ cob.labelBinding(known);
+ cob.goto_(loopStart);
+
+ cob.labelBinding(loopEnd);
+ }
+
+ /// Dynamic-path additional properties check: parent path from local variable.
+ private static void emitAdditionalCheckDynamic(CodeBuilder cob, JtdSchema.PropertiesSchema p,
+ int mapSlot, int errSlot,
+ int parentPathSlot, String schemaPath,
+ String discriminatorTag) {
+ cob.aload(mapSlot);
+ cob.invokeinterface(CD_Map, "keySet", MTD_Set);
+ cob.invokeinterface(CD_Set, "iterator", MTD_Iterator);
+ int iterSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(iterSlot);
+
+ var loopStart = cob.newLabel();
+ var loopEnd = cob.newLabel();
+
+ cob.labelBinding(loopStart);
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean);
+ cob.ifeq(loopEnd);
+
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "next", MTD_Object);
+ cob.checkcast(CD_String);
+ int keySlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(keySlot);
+
+ var known = cob.newLabel();
+ emitKnownKeyChecks(cob, p, keySlot, known, discriminatorTag);
+
+ // Build error path: parentPath + "/" + key
+ cob.aload(errSlot);
+ cob.new_(CD_JtdValidationError);
+ cob.dup();
+ cob.aload(parentPathSlot);
+ cob.ldc("/");
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ cob.aload(keySlot);
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ cob.ldc(schemaPath);
+ cob.invokespecial(CD_JtdValidationError, "", MTD_void_String_String);
+ cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object);
+ cob.pop();
+
+ cob.labelBinding(known);
+ cob.goto_(loopStart);
+
+ cob.labelBinding(loopEnd);
+ }
+
+ private static void emitKnownKeyChecks(CodeBuilder cob, JtdSchema.PropertiesSchema p,
+ int keySlot, Label known,
+ String discriminatorTag) {
+ for (final var k : p.properties().keySet()) {
+ cob.aload(keySlot);
+ cob.ldc(k);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifne(known);
+ }
+ for (final var k : p.optionalProperties().keySet()) {
+ cob.aload(keySlot);
+ cob.ldc(k);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifne(known);
+ }
+ if (discriminatorTag != null) {
+ cob.aload(keySlot);
+ cob.ldc(discriminatorTag);
+ cob.invokevirtual(CD_String, "equals", MTD_boolean_Object);
+ cob.ifne(known);
+ }
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java
new file mode 100644
index 0000000..cb066ab
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java
@@ -0,0 +1,77 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.*;
+import java.lang.constant.ClassDesc;
+import java.lang.constant.ConstantDescs;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits the class skeleton: constructor, toString, and the validate method shell.
+///
+/// The validate method delegates to [EmitNode.emit] for the actual schema logic.
+final class EmitScaffold {
+
+ private EmitScaffold() {}
+
+ static void emitConstructor(ClassBuilder clb, ClassDesc self) {
+ clb.withMethodBody(ConstantDescs.INIT_NAME,
+ MTD_void_String,
+ ClassFile.ACC_PUBLIC,
+ cob -> {
+ cob.aload(0);
+ cob.invokespecial(CD_Object, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void);
+ cob.aload(0);
+ cob.aload(1);
+ cob.putfield(self, "schemaJson", CD_String);
+ cob.return_();
+ });
+ }
+
+ static void emitToString(ClassBuilder clb, ClassDesc self) {
+ clb.withMethodBody("toString",
+ MTD_String,
+ ClassFile.ACC_PUBLIC,
+ cob -> {
+ cob.aload(0);
+ cob.getfield(self, "schemaJson", CD_String);
+ cob.areturn();
+ });
+ }
+
+ /// Emits: public JtdValidationResult validate(JsonValue instance)
+ ///
+ /// Layout: local 0 = this, local 1 = instance, local 2 = errors (ArrayList)
+ static void emitValidateMethod(ClassBuilder clb, ClassDesc self, JtdSchema schema) {
+ clb.withMethodBody("validate",
+ MTD_JtdValidationResult_JsonValue,
+ ClassFile.ACC_PUBLIC,
+ cob -> {
+ // local 0 = this, local 1 = instance
+ // allocate local 2 = errors (ArrayList)
+ int errSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.new_(CD_ArrayList);
+ cob.dup();
+ cob.invokespecial(CD_ArrayList, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void);
+ cob.astore(errSlot);
+
+ // Emit the root schema validation
+ EmitNode.emit(cob, schema, 1, errSlot, "", "");
+
+ // Build result: errors.isEmpty() ? success() : failure(errors)
+ cob.aload(errSlot);
+ cob.invokevirtual(CD_ArrayList, "isEmpty", MTD_boolean);
+ var failLabel = cob.newLabel();
+ cob.ifeq(failLabel);
+
+ cob.invokestatic(CD_JtdValidationResult, "success", MTD_JtdValidationResult);
+ cob.areturn();
+
+ cob.labelBinding(failLabel);
+ cob.aload(errSlot);
+ cob.invokestatic(CD_JtdValidationResult, "failure", MTD_JtdValidationResult_List);
+ cob.areturn();
+ });
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java
new file mode 100644
index 0000000..a430428
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java
@@ -0,0 +1,310 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+import java.lang.constant.ConstantDescs;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Type schema checks (RFC 8927 §2.2.3).
+///
+/// Each method is independently testable: feed a small `{"type":"X"}` AST
+/// fragment and verify the generated validator produces correct error pairs.
+final class EmitType {
+
+ private EmitType() {}
+
+ /// Emit: if (!(instance instanceof JsonBoolean)) addError
+ static void emitBoolean(CodeBuilder cob, int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonBoolean);
+ cob.ifne(ok);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ /// Emit: if (!(instance instanceof JsonString)) addError
+ static void emitString(CodeBuilder cob, int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifne(ok);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ /// Emit: if (!(instance instanceof JsonNumber)) addError
+ static void emitFloat(CodeBuilder cob, int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNumber);
+ cob.ifne(ok);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ /// Emit integer range check for int8..uint32.
+ ///
+ /// Checks: instance is JsonNumber, zero fractional part, and in [min, max].
+ static void emitInt(CodeBuilder cob, String type, int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ emitIntCore(cob, type, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addError(c, e, instPath, sp));
+ }
+
+ /// Emit timestamp check: JsonString + RFC 3339 regex + OffsetDateTime.parse.
+ static void emitTimestamp(CodeBuilder cob, int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ emitTimestampCore(cob, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addError(c, e, instPath, sp));
+ }
+
+ // ------------------------------------------------------------------
+ // Dynamic-path variants (materialized pathSlot)
+ // ------------------------------------------------------------------
+
+ static void emitBooleanDynamic(CodeBuilder cob, int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonBoolean);
+ cob.ifne(ok);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitStringDynamic(CodeBuilder cob, int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifne(ok);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitFloatDynamic(CodeBuilder cob, int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNumber);
+ cob.ifne(ok);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitIntDynamic(CodeBuilder cob, String type, int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ emitIntCore(cob, type, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addErrorDynamic(c, e, pathSlot, sp));
+ }
+
+ // ------------------------------------------------------------------
+ // Deferred string-segment variants (parentPathSlot + segmentSlot)
+ // Concat only on error.
+ // ------------------------------------------------------------------
+
+ static void emitBooleanDeferredStr(CodeBuilder cob, int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonBoolean);
+ cob.ifne(ok);
+ EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitStringDeferredStr(CodeBuilder cob, int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifne(ok);
+ EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitFloatDeferredStr(CodeBuilder cob, int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNumber);
+ cob.ifne(ok);
+ EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitIntDeferredStr(CodeBuilder cob, String type, int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ emitIntCore(cob, type, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addErrorDeferred(c, e, parentPathSlot, segmentSlot, sp));
+ }
+
+ static void emitTimestampDeferredStr(CodeBuilder cob, int instSlot, int errSlot,
+ int parentPathSlot, int segmentSlot,
+ String schemaPath) {
+ emitTimestampCore(cob, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addErrorDeferred(c, e, parentPathSlot, segmentSlot, sp));
+ }
+
+ // ------------------------------------------------------------------
+ // Deferred index-segment variants (prefixSlot + indexIntSlot)
+ // Concat + valueOf only on error.
+ // ------------------------------------------------------------------
+
+ static void emitBooleanDeferredIdx(CodeBuilder cob, int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonBoolean);
+ cob.ifne(ok);
+ EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitStringDeferredIdx(CodeBuilder cob, int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifne(ok);
+ EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitFloatDeferredIdx(CodeBuilder cob, int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ var ok = cob.newLabel();
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNumber);
+ cob.ifne(ok);
+ EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ static void emitIntDeferredIdx(CodeBuilder cob, String type, int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ emitIntCore(cob, type, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addErrorDeferredIdx(c, e, prefixSlot, indexSlot, sp));
+ }
+
+ static void emitTimestampDeferredIdx(CodeBuilder cob, int instSlot, int errSlot,
+ int prefixSlot, int indexSlot,
+ String schemaPath) {
+ emitTimestampCore(cob, instSlot, errSlot, schemaPath,
+ (c, e, sp) -> EmitError.addErrorDeferredIdx(c, e, prefixSlot, indexSlot, sp));
+ }
+
+ // ------------------------------------------------------------------
+ // Shared core logic extracted to avoid duplication
+ // ------------------------------------------------------------------
+
+ @FunctionalInterface
+ interface ErrorEmitter {
+ void emit(CodeBuilder cob, int errSlot, String schemaPath);
+ }
+
+ private static void emitIntCore(CodeBuilder cob, String type,
+ int instSlot, int errSlot,
+ String schemaPath, ErrorEmitter onError) {
+ long min, max;
+ switch (type) {
+ case "int8" -> { min = -128; max = 127; }
+ case "uint8" -> { min = 0; max = 255; }
+ case "int16" -> { min = -32768; max = 32767; }
+ case "uint16" -> { min = 0; max = 65535; }
+ case "int32" -> { min = Integer.MIN_VALUE; max = Integer.MAX_VALUE; }
+ case "uint32" -> { min = 0; max = 4294967295L; }
+ default -> throw new IllegalArgumentException("Unknown int type: " + type);
+ }
+
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonNumber);
+ cob.ifeq(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonNumber);
+ cob.invokeinterface(CD_JsonNumber, "toDouble", MTD_double);
+ int dSlot = cob.allocateLocal(TypeKind.DOUBLE);
+ cob.dstore(dSlot);
+
+ cob.dload(dSlot);
+ cob.dload(dSlot);
+ cob.invokestatic(CD_Math, "floor", MTD_double_double);
+ cob.dcmpl();
+ cob.ifne(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonNumber);
+ cob.invokeinterface(CD_JsonNumber, "toLong", MTD_long);
+ int lSlot = cob.allocateLocal(TypeKind.LONG);
+ cob.lstore(lSlot);
+
+ cob.lload(lSlot);
+ cob.ldc(min);
+ cob.lcmp();
+ cob.iflt(fail);
+
+ cob.lload(lSlot);
+ cob.ldc(max);
+ cob.lcmp();
+ cob.ifgt(fail);
+
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ onError.emit(cob, errSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+
+ private static void emitTimestampCore(CodeBuilder cob, int instSlot, int errSlot,
+ String schemaPath, ErrorEmitter onError) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonString);
+ cob.ifeq(fail);
+
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonString);
+ cob.invokeinterface(CD_JsonString, "string", MTD_String);
+ int strSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(strSlot);
+
+ cob.ldc("^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:(\\d{2}|60)(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2}))$");
+ cob.invokestatic(CD_Pattern, "compile", MTD_Pattern_String);
+ cob.aload(strSlot);
+ cob.invokevirtual(CD_Pattern, "matcher", MTD_Matcher_CharSequence);
+ cob.invokevirtual(CD_Matcher, "matches", MTD_boolean);
+ cob.ifeq(fail);
+
+ cob.aload(strSlot);
+ cob.ldc(":60");
+ cob.ldc(":59");
+ cob.invokevirtual(CD_String, "replace", MTD_String_CharSeq_CharSeq);
+ cob.getstatic(CD_DateTimeFormatter, "ISO_OFFSET_DATE_TIME", CD_DateTimeFormatter);
+ cob.invokestatic(CD_OffsetDateTime, "parse", MTD_OffsetDateTime_CharSeq_DTF);
+ cob.pop();
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ onError.emit(cob, errSlot, schemaPath + "/type");
+ cob.labelBinding(ok);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java
new file mode 100644
index 0000000..7883797
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java
@@ -0,0 +1,130 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.CodeBuilder;
+import java.lang.classfile.TypeKind;
+
+import json.java21.jtd.JtdSchema;
+
+import static json.java21.jtd.codegen.Descriptors.*;
+
+/// Emits bytecode for JTD Values schema (object with homogeneous values).
+final class EmitValues {
+
+ private EmitValues() {}
+
+ static void emit(CodeBuilder cob, JtdSchema.ValuesSchema v,
+ int instSlot, int errSlot,
+ String instPath, String schemaPath) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(fail);
+
+ emitLoop(cob, v, instSlot, errSlot, instPath + "/", schemaPath + "/values");
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ EmitError.addError(cob, errSlot, instPath, schemaPath + "/values");
+ cob.labelBinding(ok);
+ }
+
+ /// Dynamic-path variant: parent instancePath from local variable.
+ static void emitDynamic(CodeBuilder cob, JtdSchema.ValuesSchema v,
+ int instSlot, int errSlot,
+ int pathSlot, String schemaPath) {
+ var ok = cob.newLabel();
+ var fail = cob.newLabel();
+
+ cob.aload(instSlot);
+ cob.instanceOf(CD_JsonObject);
+ cob.ifeq(fail);
+
+ // Build prefix: parentPath + "/"
+ cob.aload(pathSlot);
+ cob.ldc("/");
+ cob.invokevirtual(CD_String, "concat", MTD_String_String);
+ int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(prefixSlot);
+
+ emitLoopDynamic(cob, v, instSlot, errSlot, prefixSlot, schemaPath + "/values");
+ cob.goto_(ok);
+
+ cob.labelBinding(fail);
+ EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/values");
+ cob.labelBinding(ok);
+ }
+
+ private static void emitLoop(CodeBuilder cob, JtdSchema.ValuesSchema v,
+ int instSlot, int errSlot,
+ String prefix, String childSchemaPath) {
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ emitEntryLoop(cob, v, mapSlot, errSlot, prefix, childSchemaPath, false, -1);
+ }
+
+ private static void emitLoopDynamic(CodeBuilder cob, JtdSchema.ValuesSchema v,
+ int instSlot, int errSlot,
+ int prefixSlot, String childSchemaPath) {
+ cob.aload(instSlot);
+ cob.checkcast(CD_JsonObject);
+ cob.invokeinterface(CD_JsonObject, "members", MTD_Map);
+ int mapSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(mapSlot);
+
+ emitEntryLoop(cob, v, mapSlot, errSlot, null, childSchemaPath, true, prefixSlot);
+ }
+
+ private static void emitEntryLoop(CodeBuilder cob, JtdSchema.ValuesSchema v,
+ int mapSlot, int errSlot,
+ String staticPrefix, String childSchemaPath,
+ boolean dynamic, int prefixSlot) {
+ if (!dynamic) {
+ cob.ldc(staticPrefix);
+ prefixSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(prefixSlot);
+ }
+
+ cob.aload(mapSlot);
+ cob.invokeinterface(CD_Map, "entrySet", MTD_Set);
+ cob.invokeinterface(CD_Set, "iterator", MTD_Iterator);
+ int iterSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(iterSlot);
+
+ var loopStart = cob.newLabel();
+ var loopEnd = cob.newLabel();
+
+ cob.labelBinding(loopStart);
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean);
+ cob.ifeq(loopEnd);
+
+ cob.aload(iterSlot);
+ cob.invokeinterface(CD_Iterator, "next", MTD_Object);
+ cob.checkcast(CD_MapEntry);
+ int entrySlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(entrySlot);
+
+ cob.aload(entrySlot);
+ cob.invokeinterface(CD_MapEntry, "getKey", MTD_Object);
+ cob.checkcast(CD_String);
+ int keySlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(keySlot);
+
+ cob.aload(entrySlot);
+ cob.invokeinterface(CD_MapEntry, "getValue", MTD_Object);
+ cob.checkcast(CD_JsonValue);
+ int valSlot = cob.allocateLocal(TypeKind.REFERENCE);
+ cob.astore(valSlot);
+
+ EmitNode.emitDeferredStr(cob, v.values(), valSlot, errSlot, prefixSlot, keySlot, childSchemaPath);
+
+ cob.goto_(loopStart);
+ cob.labelBinding(loopEnd);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java
new file mode 100644
index 0000000..93ce5c0
--- /dev/null
+++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java
@@ -0,0 +1,73 @@
+package json.java21.jtd.codegen;
+
+import java.lang.classfile.*;
+import java.lang.classfile.attribute.SourceFileAttribute;
+import java.lang.constant.ClassDesc;
+import java.lang.constant.ConstantDescs;
+import java.lang.constant.MethodTypeDesc;
+import java.lang.invoke.MethodHandles;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Logger;
+
+import jdk.sandbox.java.util.json.JsonValue;
+import json.java21.jtd.*;
+
+/// Compiles a JTD schema into a bytecode-generated [JtdValidator].
+///
+/// The generated class targets Java 21 (class file version 65) and
+/// contains only the checks the schema requires.
+///
+/// Entry point for the `JtdValidator.compileGenerated()` reflection call.
+public final class JtdCodegen {
+
+ static final Logger LOG = Logger.getLogger(JtdCodegen.class.getName());
+ private static final AtomicLong COUNTER = new AtomicLong();
+
+ private JtdCodegen() {}
+
+ /// Result of compilation including the validator and generated class statistics.
+ public record CompileResult(JtdValidator validator, int classfileBytes) {}
+
+ /// Public factory invoked by [JtdValidator.compileGenerated] via reflection.
+ public static JtdValidator compile(JsonValue schema) {
+ return compileWithStats(schema).validator();
+ }
+
+ /// Compiles the schema and returns both the validator and the generated
+ /// classfile size in bytes. Useful for benchmarking and diagnostics.
+ public static CompileResult compileWithStats(JsonValue schema) {
+ final var jtd = new Jtd();
+ final var compiled = jtd.compileToSchema(schema);
+ final var schemaJson = schema.toString();
+
+ final var className = "json/java21/jtd/codegen/Generated_" + COUNTER.incrementAndGet();
+ final var classDesc = ClassDesc.ofInternalName(className);
+
+ LOG.fine(() -> "Generating validator class: " + className);
+
+ final var bytes = ClassFile.of().build(classDesc, clb -> {
+ clb.withVersion(ClassFile.JAVA_21_VERSION, 0);
+ clb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL);
+ clb.withSuperclass(Descriptors.CD_Object);
+ clb.withInterfaceSymbols(Descriptors.CD_JtdValidator);
+ clb.with(SourceFileAttribute.of("JtdCodegen"));
+
+ clb.withField("schemaJson", Descriptors.CD_String,
+ ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL);
+
+ EmitScaffold.emitConstructor(clb, classDesc);
+ EmitScaffold.emitToString(clb, classDesc);
+ EmitScaffold.emitValidateMethod(clb, classDesc, compiled);
+ });
+
+ try {
+ final var lookup = MethodHandles.lookup();
+ final var clazz = lookup.defineClass(bytes);
+ final var ctor = clazz.getConstructor(String.class);
+ final var validator = (JtdValidator) ctor.newInstance(schemaJson);
+ return new CompileResult(validator, bytes.length);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to load generated validator: " + className, e);
+ }
+ }
+}
diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java
new file mode 100644
index 0000000..5317caa
--- /dev/null
+++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java
@@ -0,0 +1,206 @@
+package json.java21.jtd.codegen;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import json.java21.jtd.JtdValidator;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/// Benchmarks codegen vs interpreter paths across schemas of increasing
+/// complexity. Reports classfile size and throughput (validations/sec).
+///
+/// Not a microbenchmark framework -- results are indicative, not definitive.
+/// Run with `-Djava.util.logging.ConsoleHandler.level=INFO` to see output.
+class BenchmarkTest extends CodegenTestBase {
+
+ private static final int WARMUP_ITERATIONS = 50_000;
+ private static final int MEASURED_ITERATIONS = 200_000;
+
+ record Schema(String name, String schemaJson, String validJson, String invalidJson) {}
+
+ static Schema[] schemas() {
+ return new Schema[] {
+ new Schema("simple-type",
+ """
+ {"type":"string"}""",
+ "\"hello\"",
+ "42"),
+
+ new Schema("enum-5",
+ """
+ {"enum":["alpha","beta","gamma","delta","epsilon"]}""",
+ "\"gamma\"",
+ "\"zeta\""),
+
+ new Schema("nullable-int",
+ """
+ {"type":"uint8","nullable":true}""",
+ "null",
+ "\"nope\""),
+
+ new Schema("properties-3",
+ """
+ {"properties":{"name":{"type":"string"},"age":{"type":"uint8"},"active":{"type":"boolean"}}}""",
+ """
+ {"name":"Alice","age":30,"active":true}""",
+ """
+ {"name":"Alice","age":"old","active":"yes"}"""),
+
+ new Schema("props-with-optional",
+ """
+ {"properties":{"name":{"type":"string"}},"optionalProperties":{"email":{"type":"string"},"phone":{"type":"string"}}}""",
+ """
+ {"name":"Alice","email":"a@b.com"}""",
+ """
+ {"name":42,"email":123}"""),
+
+ new Schema("elements-of-type",
+ """
+ {"elements":{"type":"string"}}""",
+ """
+ ["a","b","c","d","e"]""",
+ """
+ ["a",1,"b",2,"c"]"""),
+
+ new Schema("values-of-type",
+ """
+ {"values":{"type":"uint32"}}""",
+ """
+ {"x":1,"y":2,"z":3}""",
+ """
+ {"x":1,"y":"bad","z":-1}"""),
+
+ new Schema("nested-elements-of-props",
+ """
+ {"elements":{"properties":{"id":{"type":"uint32"},"label":{"type":"string"}}}}""",
+ """
+ [{"id":1,"label":"a"},{"id":2,"label":"b"},{"id":3,"label":"c"}]""",
+ """
+ [{"id":"x","label":42},{"id":2},{"label":"c","extra":true}]"""),
+
+ new Schema("discriminator-2-variants",
+ """
+ {"discriminator":"kind","mapping":{"dog":{"properties":{"breed":{"type":"string"}}},"cat":{"properties":{"indoor":{"type":"boolean"}}}}}""",
+ """
+ {"kind":"dog","breed":"poodle"}""",
+ """
+ {"kind":"dog","breed":42}"""),
+
+ new Schema("ref-with-definitions",
+ """
+ {"definitions":{"addr":{"properties":{"street":{"type":"string"},"zip":{"type":"string"}}}},"properties":{"home":{"ref":"addr"},"work":{"ref":"addr"}}}""",
+ """
+ {"home":{"street":"1 Main","zip":"12345"},"work":{"street":"2 Oak","zip":"67890"}}""",
+ """
+ {"home":{"street":1,"zip":2},"work":"invalid"}"""),
+
+ new Schema("deep-nesting",
+ """
+ {"elements":{"values":{"properties":{"tags":{"elements":{"type":"string"}},"count":{"type":"uint32"}}}}}""",
+ """
+ [{"a":{"tags":["x","y"],"count":5}},{"b":{"tags":[],"count":0}}]""",
+ """
+ [{"a":{"tags":["x",1],"count":-1}}]"""),
+
+ new Schema("worked-example-rfc8927",
+ """
+ {"properties":{"name":{"type":"string"},"age":{"type":"uint8"},"phones":{"elements":{"properties":{"type":{"enum":["home","work","mobile"]},"number":{"type":"string"}}}},"tags":{"elements":{"type":"string"}}},"optionalProperties":{"email":{"type":"string"},"address":{"properties":{"street":{"type":"string"},"city":{"type":"string"},"zip":{"type":"string"}}}}}""",
+ """
+ {"name":"Alice","age":30,"phones":[{"type":"home","number":"555-1234"},{"type":"work","number":"555-5678"}],"tags":["vip","active"],"email":"alice@example.com","address":{"street":"1 Main","city":"NY","zip":"10001"}}""",
+ """
+ {"name":42,"age":300,"phones":[{"type":"fax","number":123}],"tags":[1,2],"email":false,"address":{"street":1,"city":2,"zip":3},"extra":"bad"}""")
+ };
+ }
+
+ @Test
+ void benchmarkAll() {
+ LOG.info("========== JTD Benchmark: Codegen vs Interpreter ==========");
+ LOG.info(String.format("Warmup: %,d iterations, Measured: %,d iterations",
+ WARMUP_ITERATIONS, MEASURED_ITERATIONS));
+ LOG.info("");
+
+ final var results = new LinkedHashMap();
+
+ for (final var s : schemas()) {
+ LOG.info("--- Schema: " + s.name + " ---");
+ LOG.info(" JSON: " + s.schemaJson.substring(0, Math.min(80, s.schemaJson.length())) + "...");
+
+ final var schema = Json.parse(s.schemaJson);
+ final var validDoc = Json.parse(s.validJson);
+ final var invalidDoc = Json.parse(s.invalidJson);
+
+ final var codegenResult = JtdCodegen.compileWithStats(schema);
+ final var codegen = codegenResult.validator();
+ final var classfileBytes = codegenResult.classfileBytes();
+ final var interpreter = JtdValidator.compile(schema);
+
+ assertThat(codegen.validate(validDoc).isValid()).isTrue();
+ assertThat(codegen.validate(invalidDoc).isValid()).isFalse();
+ assertThat(interpreter.validate(validDoc).isValid()).isTrue();
+ assertThat(interpreter.validate(invalidDoc).isValid()).isFalse();
+
+ LOG.info(" Classfile size: " + classfileBytes + " bytes");
+ LOG.info(" Schema JSON size: " + s.schemaJson.length() + " chars");
+
+ final var codegenValidNs = measure(codegen, validDoc);
+ final var codegenInvalidNs = measure(codegen, invalidDoc);
+ final var interpValidNs = measure(interpreter, validDoc);
+ final var interpInvalidNs = measure(interpreter, invalidDoc);
+
+ final var speedupValid = (double) interpValidNs / codegenValidNs;
+ final var speedupInvalid = (double) interpInvalidNs / codegenInvalidNs;
+
+ LOG.info(String.format(" Valid doc: codegen %,d ns/op, interp %,d ns/op (%.1fx)",
+ codegenValidNs, interpValidNs, speedupValid));
+ LOG.info(String.format(" Invalid doc: codegen %,d ns/op, interp %,d ns/op (%.1fx)",
+ codegenInvalidNs, interpInvalidNs, speedupInvalid));
+ LOG.info("");
+
+ results.put(s.name, new BenchResult(classfileBytes, s.schemaJson.length(),
+ codegenValidNs, interpValidNs, codegenInvalidNs, interpInvalidNs));
+ }
+
+ LOG.info("========== Summary ==========");
+ LOG.info(String.format("%-30s %8s %8s | %10s %10s %6s | %10s %10s %6s",
+ "Schema", "Class B", "JSON Ch",
+ "CG val ns", "Int val ns", "x",
+ "CG inv ns", "Int inv ns", "x"));
+ LOG.info("-".repeat(120));
+
+ for (final var entry : results.entrySet()) {
+ final var r = entry.getValue();
+ LOG.info(String.format("%-30s %,8d %,8d | %,10d %,10d %5.1fx | %,10d %,10d %5.1fx",
+ entry.getKey(), r.classfileBytes, r.schemaJsonChars,
+ r.codegenValidNs, r.interpValidNs, (double) r.interpValidNs / r.codegenValidNs,
+ r.codegenInvalidNs, r.interpInvalidNs, (double) r.interpInvalidNs / r.codegenInvalidNs));
+ }
+
+ LOG.info("");
+ final var avgSpeedupValid = results.values().stream()
+ .mapToDouble(r -> (double) r.interpValidNs / r.codegenValidNs)
+ .average().orElse(0);
+ final var avgSpeedupInvalid = results.values().stream()
+ .mapToDouble(r -> (double) r.interpInvalidNs / r.codegenInvalidNs)
+ .average().orElse(0);
+ LOG.info(String.format("Average speedup: valid=%.1fx, invalid=%.1fx", avgSpeedupValid, avgSpeedupInvalid));
+ }
+
+ private long measure(JtdValidator validator, JsonValue doc) {
+ IntStream.range(0, WARMUP_ITERATIONS).forEach(_ -> validator.validate(doc));
+
+ final var start = System.nanoTime();
+ IntStream.range(0, MEASURED_ITERATIONS).forEach(_ -> validator.validate(doc));
+ final var elapsed = System.nanoTime() - start;
+
+ return elapsed / MEASURED_ITERATIONS;
+ }
+
+ record BenchResult(int classfileBytes, int schemaJsonChars,
+ long codegenValidNs, long interpValidNs,
+ long codegenInvalidNs, long interpInvalidNs) {}
+}
diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java
new file mode 100644
index 0000000..17a4870
--- /dev/null
+++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java
@@ -0,0 +1,92 @@
+package json.java21.jtd.codegen;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonArray;
+import jdk.sandbox.java.util.json.JsonObject;
+import jdk.sandbox.java.util.json.JsonString;
+import jdk.sandbox.java.util.json.JsonValue;
+import json.java21.jtd.JtdValidationError;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/// Runs the official json-typedef-spec validation test suite against
+/// the codegen path and cross-validates against the interpreter.
+///
+/// Test data: `jtd-spec-validation.json` from
+///
+class CodegenSpecConformanceTest extends CodegenTestBase {
+
+ static Stream cases() throws IOException {
+ // Extract test suite from ZIP (same data as IT tests, avoids committing large JSON)
+ try (final var raw = JtdTestDataExtractor.getValidationTestDataStream()) {
+ final var jsonText = new String(raw.readAllBytes(), StandardCharsets.UTF_8);
+ final var root = Json.parse(jsonText);
+ assert root instanceof JsonObject : "expected top-level object";
+ final var obj = (JsonObject) root;
+
+ return obj.members().entrySet().stream()
+ .map(entry -> Arguments.of(
+ entry.getKey(),
+ entry.getValue()));
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("cases")
+ void codegenMatchesSpecSuite(String name, JsonValue caseValue) {
+ LOG.info("SPEC-CODEGEN: " + name);
+
+ if (name.contains("recursive schema")) {
+ LOG.info("SKIP: recursive refs require method-call emission (not yet implemented)");
+ return;
+ }
+
+ final var caseObj = (JsonObject) caseValue;
+ final var schema = caseObj.members().get("schema");
+ final var instance = caseObj.members().get("instance");
+ final var expectedErrors = (JsonArray) caseObj.members().get("errors");
+
+ final var codegen = JtdCodegen.compile(schema);
+ final var result = codegen.validate(instance);
+
+ final var expected = expectedErrors.elements().stream()
+ .map(e -> {
+ final var errObj = (JsonObject) e;
+ final var ip = toJsonPointer((JsonArray) errObj.members().get("instancePath"));
+ final var sp = toJsonPointer((JsonArray) errObj.members().get("schemaPath"));
+ return new JtdValidationError(ip, sp);
+ })
+ .sorted(ERR_CMP)
+ .toList();
+
+ final var actual = result.errors().stream()
+ .sorted(ERR_CMP)
+ .toList();
+
+ assertThat(actual)
+ .as("errors for: " + name)
+ .containsExactlyElementsOf(expected);
+ }
+
+ private static String toJsonPointer(JsonArray tokens) {
+ if (tokens.elements().isEmpty()) return "";
+ final var sb = new StringBuilder();
+ for (final var token : tokens.elements()) {
+ sb.append('/');
+ sb.append(((JsonString) token).string());
+ }
+ return sb.toString();
+ }
+
+ private static final Comparator ERR_CMP =
+ Comparator.comparing(JtdValidationError::instancePath)
+ .thenComparing(JtdValidationError::schemaPath);
+}
diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java
new file mode 100644
index 0000000..e610ae6
--- /dev/null
+++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java
@@ -0,0 +1,54 @@
+package json.java21.jtd.codegen;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInfo;
+
+import java.util.Locale;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/// Base class for all codegen tests.
+///
+/// Mirrors the JTD module's JUL setup so tests behave similarly when run
+/// standalone from this module.
+public class CodegenTestBase {
+
+ static final Logger LOG = Logger.getLogger("json.java21.jtd.codegen");
+
+ @BeforeAll
+ static void configureJul() {
+ Logger root = Logger.getLogger("");
+ String levelProp = System.getProperty("java.util.logging.ConsoleHandler.level");
+ Level targetLevel = Level.INFO;
+ if (levelProp != null) {
+ try {
+ targetLevel = Level.parse(levelProp.trim());
+ } catch (IllegalArgumentException ex) {
+ try {
+ targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException ignored) {
+ System.err.println("Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp);
+ }
+ }
+ }
+ if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) {
+ root.setLevel(targetLevel);
+ }
+ for (Handler handler : root.getHandlers()) {
+ Level handlerLevel = handler.getLevel();
+ if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) {
+ handler.setLevel(targetLevel);
+ }
+ }
+ }
+
+ @BeforeEach
+ void announce(TestInfo testInfo) {
+ final String cls = testInfo.getTestClass().map(Class::getSimpleName).orElse("UnknownTest");
+ final String name = testInfo.getTestMethod().map(java.lang.reflect.Method::getName)
+ .orElseGet(testInfo::getDisplayName);
+ LOG.info(() -> "TEST: " + cls + "#" + name);
+ }
+}
diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java
new file mode 100644
index 0000000..121580b
--- /dev/null
+++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java
@@ -0,0 +1,263 @@
+package json.java21.jtd.codegen;
+
+import jdk.sandbox.java.util.json.Json;
+import json.java21.jtd.JtdValidationError;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/// Cross-validates the interpreter and codegen paths produce identical
+/// RFC 8927 `(instancePath, schemaPath)` error sets for the same
+/// schema/instance pairs.
+///
+/// Every case from [json.java21.jtd.JtdValidatorTest] is replicated here
+/// plus additional cases from the RFC 8927 spec worked examples.
+class CrossValidationTest extends CodegenTestBase {
+
+ record Case(String name, String schema, String instance) {}
+
+ static Stream cases() {
+ return Stream.of(
+ // -- Empty --
+ args("empty accepts null", "{}", "null"),
+ args("empty accepts number", "{}", "42"),
+ args("empty accepts string", "{}", "\"hi\""),
+ args("empty accepts array", "{}", "[1,2]"),
+ args("empty accepts object", "{}", "{\"a\":1}"),
+
+ // -- Type: boolean --
+ args("boolean valid true", "{\"type\":\"boolean\"}", "true"),
+ args("boolean valid false", "{\"type\":\"boolean\"}", "false"),
+ args("boolean rejects number", "{\"type\":\"boolean\"}", "42"),
+ args("boolean rejects string", "{\"type\":\"boolean\"}", "\"hi\""),
+
+ // -- Type: string --
+ args("string valid", "{\"type\":\"string\"}", "\"hello\""),
+ args("string rejects number", "{\"type\":\"string\"}", "42"),
+ args("string rejects bool", "{\"type\":\"string\"}", "true"),
+
+ // -- Type: float --
+ args("float64 valid int", "{\"type\":\"float64\"}", "42"),
+ args("float64 valid decimal", "{\"type\":\"float64\"}", "3.14"),
+ args("float32 rejects string", "{\"type\":\"float32\"}", "\"hi\""),
+
+ // -- Type: uint8 --
+ args("uint8 valid 0", "{\"type\":\"uint8\"}", "0"),
+ args("uint8 valid 255", "{\"type\":\"uint8\"}", "255"),
+ args("uint8 valid 3.0", "{\"type\":\"uint8\"}", "3.0"),
+ args("uint8 rejects 256", "{\"type\":\"uint8\"}", "256"),
+ args("uint8 rejects -1", "{\"type\":\"uint8\"}", "-1"),
+ args("uint8 rejects 3.5", "{\"type\":\"uint8\"}", "3.5"),
+ args("uint8 rejects string", "{\"type\":\"uint8\"}", "\"hi\""),
+
+ // -- Type: int8 --
+ args("int8 valid -128", "{\"type\":\"int8\"}", "-128"),
+ args("int8 valid 127", "{\"type\":\"int8\"}", "127"),
+ args("int8 rejects 128", "{\"type\":\"int8\"}", "128"),
+
+ // -- Type: int32 --
+ args("int32 valid max", "{\"type\":\"int32\"}", "2147483647"),
+ args("int32 valid min", "{\"type\":\"int32\"}", "-2147483648"),
+ args("int32 rejects overflow", "{\"type\":\"int32\"}", "2147483648"),
+ args("int32 rejects decimal", "{\"type\":\"int32\"}", "3.14"),
+
+ // -- Type: uint32 --
+ args("uint32 valid max", "{\"type\":\"uint32\"}", "4294967295"),
+ args("uint32 rejects overflow", "{\"type\":\"uint32\"}", "4294967296"),
+
+ // -- Type: timestamp --
+ args("timestamp valid UTC", "{\"type\":\"timestamp\"}", "\"1990-12-31T23:59:59Z\""),
+ args("timestamp valid offset", "{\"type\":\"timestamp\"}", "\"2024-01-15T10:30:00+05:00\""),
+ args("timestamp valid leap second", "{\"type\":\"timestamp\"}", "\"1990-12-31T23:59:60Z\""),
+ args("timestamp rejects bad", "{\"type\":\"timestamp\"}", "\"not-a-date\""),
+ args("timestamp rejects number", "{\"type\":\"timestamp\"}", "42"),
+
+ // -- Enum --
+ args("enum valid", "{\"enum\":[\"a\",\"b\",\"c\"]}", "\"a\""),
+ args("enum rejects unknown", "{\"enum\":[\"a\",\"b\"]}", "\"c\""),
+ args("enum rejects number", "{\"enum\":[\"a\"]}", "42"),
+
+ // -- Nullable --
+ args("nullable string accepts null", "{\"type\":\"string\",\"nullable\":true}", "null"),
+ args("nullable string accepts string", "{\"type\":\"string\",\"nullable\":true}", "\"hi\""),
+ args("nullable string rejects number", "{\"type\":\"string\",\"nullable\":true}", "42"),
+
+ // -- Elements --
+ args("elements valid", "{\"elements\":{\"type\":\"string\"}}", "[\"a\",\"b\"]"),
+ args("elements empty", "{\"elements\":{\"type\":\"string\"}}", "[]"),
+ args("elements rejects non-array", "{\"elements\":{\"type\":\"string\"}}", "42"),
+ args("elements child errors", "{\"elements\":{\"type\":\"string\"}}", "[\"ok\",42,\"fine\",true]"),
+
+ // -- Properties --
+ args("props valid",
+ "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"}}}",
+ "{\"name\":\"Alice\",\"age\":30}"),
+ args("props missing required",
+ "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"}}}",
+ "{\"name\":\"Alice\"}"),
+ args("props additional rejected",
+ "{\"properties\":{\"name\":{\"type\":\"string\"}}}",
+ "{\"name\":\"Alice\",\"extra\":true}"),
+ args("props child error",
+ "{\"properties\":{\"age\":{\"type\":\"uint8\"}}}",
+ "{\"age\":\"not a number\"}"),
+ args("props rejects non-object",
+ "{\"properties\":{\"x\":{\"type\":\"string\"}}}",
+ "42"),
+
+ // -- Optional properties --
+ args("optional-only rejects non-object",
+ "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}",
+ "42"),
+ args("optional absent ok",
+ "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}",
+ "{}"),
+ args("optional present valid",
+ "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}",
+ "{\"email\":\"a@b\"}"),
+ args("optional present invalid",
+ "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}",
+ "{\"email\":42}"),
+
+ // -- Values --
+ args("values valid", "{\"values\":{\"type\":\"string\"}}", "{\"a\":\"x\",\"b\":\"y\"}"),
+ args("values rejects non-object", "{\"values\":{\"type\":\"string\"}}", "42"),
+ args("values child error", "{\"values\":{\"type\":\"string\"}}", "{\"a\":\"ok\",\"b\":42}"),
+
+ // -- Discriminator --
+ args("disc valid",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "{\"kind\":\"a\",\"x\":\"ok\"}"),
+ args("disc not object",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "42"),
+ args("disc missing tag",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "{\"x\":1}"),
+ args("disc tag not string",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "{\"kind\":42}"),
+ args("disc tag not in mapping",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "{\"kind\":\"unknown\"}"),
+ args("disc variant error",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}",
+ "{\"kind\":\"a\",\"x\":42}"),
+
+ // -- Ref --
+ args("ref valid",
+ "{\"definitions\":{\"addr\":{\"type\":\"string\"}},\"ref\":\"addr\"}",
+ "\"hello\""),
+ args("ref invalid",
+ "{\"definitions\":{\"addr\":{\"type\":\"string\"}},\"ref\":\"addr\"}",
+ "42"),
+
+ // -- Nested: elements of properties --
+ args("elements of properties valid",
+ "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}",
+ "[{\"n\":\"ok\"}]"),
+ args("elements of properties invalid child",
+ "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}",
+ "[{\"n\":42}]"),
+ args("elements of properties missing",
+ "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}",
+ "[{}]"),
+
+ // -- Nested: additional properties inside array elements --
+ args("elements of properties additional rejected",
+ "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}",
+ "[{\"n\":\"ok\",\"extra\":true}]"),
+
+ // -- Nested: non-object inside elements of properties --
+ args("elements of properties non-object",
+ "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}",
+ "[42]"),
+
+ // -- Nested: discriminator inside values --
+ args("values of discriminator valid",
+ "{\"values\":{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}}",
+ "{\"k1\":{\"kind\":\"a\",\"x\":\"ok\"}}"),
+ args("values of discriminator invalid",
+ "{\"values\":{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}}",
+ "{\"k1\":{\"kind\":\"a\",\"x\":42}}"),
+
+ // -- Nested: elements of elements --
+ args("elements of elements valid",
+ "{\"elements\":{\"elements\":{\"type\":\"string\"}}}",
+ "[[\"a\",\"b\"]]"),
+ args("elements of elements invalid",
+ "{\"elements\":{\"elements\":{\"type\":\"string\"}}}",
+ "[[\"a\",42]]"),
+
+ // -- Nested: values of values --
+ args("values of values valid",
+ "{\"values\":{\"values\":{\"type\":\"string\"}}}",
+ "{\"a\":{\"b\":\"ok\"}}"),
+ args("values of values invalid",
+ "{\"values\":{\"values\":{\"type\":\"string\"}}}",
+ "{\"a\":{\"b\":42}}"),
+
+ // -- Discriminator with multi-variant mapping --
+ args("disc multi-variant valid dog",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"dog\":{\"properties\":{\"breed\":{\"type\":\"string\"}}},\"cat\":{\"properties\":{\"indoor\":{\"type\":\"boolean\"}}}}}",
+ "{\"kind\":\"dog\",\"breed\":\"poodle\"}"),
+ args("disc multi-variant valid cat",
+ "{\"discriminator\":\"kind\",\"mapping\":{\"dog\":{\"properties\":{\"breed\":{\"type\":\"string\"}}},\"cat\":{\"properties\":{\"indoor\":{\"type\":\"boolean\"}}}}}",
+ "{\"kind\":\"cat\",\"indoor\":true}"),
+
+ // -- Worked example from spec --
+ args("worked example",
+ "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"},\"tags\":{\"elements\":{\"type\":\"string\"}}},\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}",
+ "{\"name\":\"Alice\",\"age\":300,\"tags\":[\"a\",42],\"extra\":true}")
+ );
+ }
+
+ private static Arguments args(String name, String schema, String instance) {
+ return Arguments.of(name, schema, instance);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("cases")
+ void interpreterAndCodegenAgree(String name, String schemaJson, String instanceJson) {
+ final var schema = Json.parse(schemaJson);
+ final var instance = Json.parse(instanceJson);
+
+ final var interpreter = json.java21.jtd.JtdValidator.compile(schema);
+ final var codegen = JtdCodegen.compile(schema);
+
+ final var interpResult = interpreter.validate(instance);
+ final var codegenResult = codegen.validate(instance);
+
+ assertThat(codegenResult.isValid())
+ .as("isValid for: " + name)
+ .isEqualTo(interpResult.isValid());
+
+ final var interpErrors = sorted(interpResult.errors());
+ final var codegenErrors = sorted(codegenResult.errors());
+
+ assertThat(codegenErrors)
+ .as("error set for: " + name)
+ .containsExactlyElementsOf(interpErrors);
+ }
+
+ private static List sorted(List errors) {
+ return errors.stream()
+ .sorted(Comparator.comparing(JtdValidationError::instancePath)
+ .thenComparing(JtdValidationError::schemaPath))
+ .toList();
+ }
+
+ @org.junit.jupiter.api.Test
+ void toStringReturnsOriginalSchemaJson() {
+ LOG.info("CROSS-VALIDATE: toString");
+ final var v = JtdCodegen.compile(Json.parse("{\"type\": \"string\"}"));
+ assertThat(v.toString()).contains("type");
+ assertThat(v.toString()).contains("string");
+ }
+}
diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java
new file mode 100644
index 0000000..fa12957
--- /dev/null
+++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java
@@ -0,0 +1,68 @@
+package json.java21.jtd.codegen;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.logging.Logger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/// Shared utility for extracting the JTD test suite from the embedded ZIP file.
+/// Used by both unit tests and integration tests to avoid committing large JSON files.
+final class JtdTestDataExtractor {
+
+ private static final Logger LOG = Logger.getLogger("json.java21.jtd.codegen");
+ // Reference the ZIP file from the sibling json-java21-jtd module
+ private static final Path ZIP_FILE = Paths.get("../json-java21-jtd/src/test/resources/jtd-test-suite.zip");
+ private static final Path TARGET_DIR = Paths.get("target/test-data");
+ private static final Path VALIDATION_FILE = TARGET_DIR.resolve("json-typedef-spec-2025-09-27/tests/validation.json");
+
+ private JtdTestDataExtractor() {
+ // Utility class
+ }
+
+ /// Ensures the test suite is extracted and returns the path to validation.json.
+ /// Extraction happens at most once per build (target/ is cleaned between builds).
+ static synchronized Path ensureValidationTestData() throws IOException {
+ if (Files.exists(VALIDATION_FILE)) {
+ LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_FILE);
+ return VALIDATION_FILE;
+ }
+
+ if (!Files.exists(ZIP_FILE)) {
+ throw new RuntimeException("JTD test suite ZIP not found: " + ZIP_FILE.toAbsolutePath());
+ }
+
+ LOG.info(() -> "Extracting JTD test suite from: " + ZIP_FILE);
+ Files.createDirectories(TARGET_DIR);
+
+ try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(ZIP_FILE))) {
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) {
+ Path outputPath = TARGET_DIR.resolve(entry.getName());
+ Files.createDirectories(outputPath.getParent());
+ Files.copy(zis, outputPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ zis.closeEntry();
+ }
+ }
+
+ if (!Files.exists(VALIDATION_FILE)) {
+ throw new RuntimeException("Extraction completed but validation.json not found: " + VALIDATION_FILE);
+ }
+
+ LOG.info(() -> "JTD test suite extracted successfully");
+ return VALIDATION_FILE;
+ }
+
+ /// Returns an InputStream for the validation test data, extracting if necessary.
+ /// Suitable for use with classpath-style resource loading patterns.
+ static InputStream getValidationTestDataStream() throws IOException {
+ Path dataFile = ensureValidationTestData();
+ return Files.newInputStream(dataFile);
+ }
+}
diff --git a/json-java21-jtd/JTD_CODEGEN_SPEC.md b/json-java21-jtd/JTD_CODEGEN_SPEC.md
new file mode 100644
index 0000000..f4d98e7
--- /dev/null
+++ b/json-java21-jtd/JTD_CODEGEN_SPEC.md
@@ -0,0 +1,706 @@
+# JTD Code Generation Specification
+
+A language-independent specification for compiling RFC 8927 JSON Type Definition
+schemas into target-language source code that validates JSON documents. The
+generated code contains exactly the checks the schema requires -- no
+interpreter, no AST, no runtime stack, no dead code.
+
+## 1. Terminology
+
+| Term | Meaning |
+|---|---|
+| **schema** | A JSON object conforming to RFC 8927. |
+| **instance** | The JSON value being validated at runtime. |
+| **form** | One of the 8 mutually-exclusive schema shapes defined in RFC 8927 plus the nullable modifier. |
+| **AST node** | An immutable, tagged value representing one compiled schema form. Used during generation, discarded after. |
+| **error** | A pair of JSON Pointers: `(instancePath, schemaPath)`. |
+| **definitions** | A flat string-keyed map of named AST nodes, resolved at compile time. Each becomes a generated function. |
+
+## 2. Overview
+
+A JTD code generator operates in two phases:
+
+1. **Parse**: Read the JTD schema JSON and compile it into an intermediate
+ AST of immutable nodes (Section 3).
+2. **Emit**: Walk the AST and emit target-language source code. Each AST
+ node maps to a specific code pattern. The AST is discarded after
+ emission (Section 5).
+
+The generated code is a standalone validation function. When executed against
+a JSON instance, it produces the same `(instancePath, schemaPath)` error
+pairs that RFC 8927 Section 3.3 specifies.
+
+## 3. Intermediate AST
+
+The AST is used only during generation. It is not present in the output.
+
+### 3.1 Node Types
+
+```
+Node =
+ | Empty -- {}
+ | Ref { name: String } -- {"ref": "..."}
+ | Type { type: TypeKeyword } -- {"type": "..."}
+ | Enum { values: List } -- {"enum": [...]}
+ | Elements { schema: Node } -- {"elements": ...}
+ | Properties { required: Map, -- {"properties": ...}
+ optional: Map, -- {"optionalProperties": ...}
+ additional: Boolean } -- {"additionalProperties": ...}
+ | Values { schema: Node } -- {"values": ...}
+ | Discrim { tag: String, mapping: Map} -- {"discriminator":...,"mapping":...}
+ | Nullable { inner: Node } -- any form + "nullable": true
+```
+
+`TypeKeyword` is one of the 12 strings defined in RFC 8927 Section 2.2.3:
+
+```
+TypeKeyword = boolean | string | timestamp
+ | int8 | uint8 | int16 | uint16 | int32 | uint32
+ | float32 | float64
+```
+
+### 3.2 Compilation Algorithm
+
+```
+compile(json, isRoot=true, definitions) -> Node:
+
+ REQUIRE json is a JSON object
+
+ IF isRoot:
+ IF json has key "definitions":
+ REQUIRE json["definitions"] is a JSON object
+ -- Pass 1: register all keys as placeholders for forward refs
+ FOR EACH key in json["definitions"]:
+ definitions[key] = PLACEHOLDER
+ -- Pass 2: compile each definition
+ FOR EACH key in json["definitions"]:
+ definitions[key] = compile(json["definitions"][key], isRoot=false, definitions)
+ ELSE:
+ REQUIRE json does NOT have key "definitions"
+
+ -- Detect form
+ forms = []
+ IF json has "ref": forms += "ref"
+ IF json has "type": forms += "type"
+ IF json has "enum": forms += "enum"
+ IF json has "elements": forms += "elements"
+ IF json has "values": forms += "values"
+ IF json has "discriminator": forms += "discriminator"
+ IF json has "properties" OR json has "optionalProperties":
+ forms += "properties"
+
+ REQUIRE |forms| <= 1
+
+ -- Compile form
+ node = MATCH forms:
+ [] -> Empty
+ ["ref"] -> compileRef(json, definitions)
+ ["type"] -> compileType(json)
+ ["enum"] -> compileEnum(json)
+ ["elements"] -> compileElements(json, definitions)
+ ["properties"] -> compileProperties(json, definitions)
+ ["values"] -> compileValues(json, definitions)
+ ["discriminator"]-> compileDiscriminator(json, definitions)
+
+ -- Nullable modifier wraps any form
+ IF json has "nullable" AND json["nullable"] == true:
+ node = Nullable { inner: node }
+
+ RETURN node
+```
+
+### 3.3 Form-Specific Compilation
+
+**Ref**:
+```
+compileRef(json, definitions):
+ name = json["ref"] -- must be a string
+ REQUIRE name IN definitions -- forward refs are valid (placeholder exists)
+ RETURN Ref { name }
+```
+
+**Type**:
+```
+compileType(json):
+ t = json["type"] -- must be a string
+ REQUIRE t IN TypeKeyword
+ RETURN Type { type: t }
+```
+
+**Enum**:
+```
+compileEnum(json):
+ values = json["enum"] -- must be a non-empty array of strings
+ REQUIRE no duplicates in values
+ RETURN Enum { values }
+```
+
+**Elements**:
+```
+compileElements(json, definitions):
+ inner = compile(json["elements"], isRoot=false, definitions)
+ RETURN Elements { schema: inner }
+```
+
+**Properties**:
+```
+compileProperties(json, definitions):
+ req = {}
+ opt = {}
+ IF json has "properties":
+ FOR EACH (key, schema) in json["properties"]:
+ req[key] = compile(schema, isRoot=false, definitions)
+ IF json has "optionalProperties":
+ FOR EACH (key, schema) in json["optionalProperties"]:
+ opt[key] = compile(schema, isRoot=false, definitions)
+ REQUIRE keys(req) INTERSECT keys(opt) == {}
+ additional = json.get("additionalProperties", false)
+ RETURN Properties { required: req, optional: opt, additional }
+```
+
+**Values**:
+```
+compileValues(json, definitions):
+ inner = compile(json["values"], isRoot=false, definitions)
+ RETURN Values { schema: inner }
+```
+
+**Discriminator**:
+```
+compileDiscriminator(json, definitions):
+ tag = json["discriminator"] -- must be a string
+ REQUIRE json has "mapping"
+ mapping = {}
+ FOR EACH (key, schema) in json["mapping"]:
+ node = compile(schema, isRoot=false, definitions)
+ REQUIRE node is Properties -- not Nullable, not any other form
+ REQUIRE tag NOT IN node.required
+ REQUIRE tag NOT IN node.optional
+ mapping[key] = node
+ RETURN Discrim { tag, mapping }
+```
+
+### 3.4 Compile-Time Invariants
+
+After compilation, the following are guaranteed:
+- Every `Ref.name` resolves to an entry in `definitions`.
+- Every `Discrim.mapping` value is a `Properties` node (not nullable).
+- No `Properties` node has overlapping required/optional keys.
+- The AST is immutable. No node is modified after construction.
+
+## 4. Type Checking Reference
+
+Exact semantics for each `TypeKeyword`. The code generator emits exactly
+this check, inlined, for each type keyword it encounters.
+
+### 4.1 boolean
+
+```
+value is a JSON boolean (true or false)
+```
+
+Target-language expression examples:
+- JavaScript: `typeof v === "boolean"`
+- Java: `v instanceof JsonBoolean`
+- Python: `isinstance(v, bool)`
+
+### 4.2 string
+
+```
+value is a JSON string
+```
+
+Target-language expression examples:
+- JavaScript: `typeof v === "string"`
+- Java: `v instanceof JsonString`
+- Python: `isinstance(v, str)`
+
+### 4.3 timestamp
+
+```
+value is a JSON string
+AND value matches the RFC 3339 date-time production
+ (regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}|60)(\.\d+)?(Z|[+-]\d{2}:\d{2})$)
+AND the date-time is parseable (accounting for leap seconds by
+ normalizing :60 to :59 before parsing)
+```
+
+Target-language expression examples:
+- JavaScript: `typeof v === "string" && !Number.isNaN(Date.parse(v))` (simplified;
+ a full implementation needs the regex for leap-second support)
+- Java: regex match + `OffsetDateTime.parse(normalized)`
+
+### 4.4 float32, float64
+
+```
+value is a JSON number (any finite number; no range check)
+```
+
+RFC 8927 does not distinguish float32 from float64 at the validation level.
+Both accept any JSON number.
+
+Target-language expression examples:
+- JavaScript: `typeof v === "number" && Number.isFinite(v)`
+- Java: `v instanceof JsonNumber`
+
+### 4.5 Integer types
+
+All integer types share the same two-step check:
+
+```
+value is a JSON number
+AND value has zero fractional part (floor(value) == value)
+AND value is within the type's range (inclusive)
+```
+
+| Type | Min | Max |
+|---|---|---|
+| int8 | -128 | 127 |
+| uint8 | 0 | 255 |
+| int16 | -32768 | 32767 |
+| uint16 | 0 | 65535 |
+| int32 | -2147483648 | 2147483647 |
+| uint32 | 0 | 4294967295 |
+
+Note: `3.0` is a valid int8. `3.5` is not. This is value-based, not
+syntax-based.
+
+Target-language expression examples:
+- JavaScript (uint8): `typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 255`
+- Java (uint8): `v instanceof JsonNumber n && n.toDouble() == Math.floor(n.toDouble()) && n.toLong() >= 0 && n.toLong() <= 255`
+
+## 5. Emission Rules
+
+The code generator walks the AST and emits target-language source code.
+Each AST node maps to a specific code pattern. The central rule:
+
+**Emit only what the schema requires. If the schema does not mention a
+form, the generated code does not contain any logic for that form.**
+
+### 5.1 Generated Code Structure
+
+The generator emits:
+
+1. **One function per definition** -- named `validate_`, taking
+ `(instance, errors, instancePath)` as parameters. Only emitted if the
+ schema has definitions.
+
+2. **One exported `validate(instance)` function** -- the entry point. Creates
+ the error list, calls the root validation logic, returns the error list.
+
+3. **No helpers, no libraries, no imports.** Every check is inlined. If the
+ schema uses only `"type": "string"`, the generated code contains one
+ `typeof` check and nothing else.
+
+### 5.2 Node-to-Code Mapping
+
+#### Empty
+
+Emit nothing. No check. No code.
+
+If an Empty node is a required property value, the generated code checks
+that the key exists but does not validate the value:
+
+```javascript
+// Schema: {"properties": {"data": {}}}
+if (!("data" in obj)) e.push({instancePath: p, schemaPath: sp + "/properties/data"});
+// No else branch -- empty schema accepts any value
+```
+
+#### Nullable
+
+Emit a null guard before the inner check:
+
+```javascript
+// Schema: {"type": "string", "nullable": true}
+if (v !== null) {
+ if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"});
+}
+```
+
+If the inner node is Empty, the nullable wraps nothing -- emit only the
+null guard (which passes everything, so emit nothing at all).
+
+#### Type
+
+Emit the type-specific check inlined. No helper function.
+
+```javascript
+// "type": "string"
+if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"});
+
+// "type": "uint8"
+if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255)
+ e.push({instancePath: p, schemaPath: sp + "/type"});
+
+// "type": "boolean"
+if (typeof v !== "boolean") e.push({instancePath: p, schemaPath: sp + "/type"});
+
+// "type": "float64"
+if (typeof v !== "number" || !Number.isFinite(v))
+ e.push({instancePath: p, schemaPath: sp + "/type"});
+```
+
+#### Enum
+
+Emit a set-membership check. For small enums, inline the array. For large
+enums, a code generator MAY hoist the array to module scope as a constant.
+
+```javascript
+// "enum": ["a", "b", "c"]
+if (typeof v !== "string" || !["a","b","c"].includes(v))
+ e.push({instancePath: p, schemaPath: sp + "/enum"});
+```
+
+Note: the string type guard is required because RFC 8927 specifies that
+non-string values fail enum validation.
+
+#### Elements
+
+Emit an array type guard, then a loop. The loop body is the generated
+check for the element schema.
+
+```javascript
+// "elements": {"type": "string"}
+if (!Array.isArray(v)) {
+ e.push({instancePath: p, schemaPath: sp + "/elements"});
+} else {
+ for (let i = 0; i < v.length; i++) {
+ if (typeof v[i] !== "string")
+ e.push({instancePath: p + "/" + i, schemaPath: sp + "/elements/type"});
+ }
+}
+```
+
+If the element schema is a complex type (Properties, Discrim), emit a
+function call in the loop body instead of inlining.
+
+For nested arrays (arrays of arrays), a code generator MAY inline nested
+loops up to a configurable depth (e.g. 3 levels) for performance, falling
+back to function calls beyond that depth.
+
+#### Properties
+
+Emit an object type guard, then:
+1. One presence check per required key.
+2. Inlined value checks for each required and optional property.
+3. A key-rejection loop if `additional == false`.
+
+```javascript
+// Schema: {"properties":{"name":{"type":"string"}}, "optionalProperties":{"age":{"type":"uint8"}}}
+if (v === null || typeof v !== "object" || Array.isArray(v)) {
+ e.push({instancePath: p, schemaPath: sp + "/properties"});
+} else {
+ // Required properties
+ if (!("name" in v)) e.push({instancePath: p, schemaPath: sp + "/properties/name"});
+ else if (typeof v["name"] !== "string")
+ e.push({instancePath: p + "/name", schemaPath: sp + "/properties/name/type"});
+
+ // Optional properties
+ if ("age" in v) {
+ const a = v["age"];
+ if (typeof a !== "number" || !Number.isInteger(a) || a < 0 || a > 255)
+ e.push({instancePath: p + "/age", schemaPath: sp + "/optionalProperties/age/type"});
+ }
+
+ // Additional properties (only emitted when additional == false)
+ for (const k in v) {
+ if (k !== "name" && k !== "age")
+ e.push({instancePath: p + "/" + k, schemaPath: sp});
+ }
+}
+```
+
+If `additional` is `true`, the for-in loop is **not emitted at all**.
+
+If a property value's schema is a complex type (Properties, Elements, etc.),
+emit a function call instead of inlining. If it is a leaf (Type, Enum,
+Empty), inline it.
+
+#### Values
+
+Emit an object type guard, then a for-in loop. The loop body is the
+generated check for the value schema.
+
+```javascript
+// "values": {"type": "string"}
+if (v === null || typeof v !== "object" || Array.isArray(v)) {
+ e.push({instancePath: p, schemaPath: sp + "/values"});
+} else {
+ for (const k in v) {
+ if (typeof v[k] !== "string")
+ e.push({instancePath: p + "/" + k, schemaPath: sp + "/values/type"});
+ }
+}
+```
+
+#### Discriminator
+
+Emit a 5-step sequential check, then a switch/if-else dispatching to the
+variant validator.
+
+```javascript
+// "discriminator": "type", "mapping": {"a": {...}, "b": {...}}
+if (v === null || typeof v !== "object" || Array.isArray(v)) {
+ e.push({instancePath: p, schemaPath: sp + "/discriminator"});
+} else if (!("type" in v)) {
+ e.push({instancePath: p, schemaPath: sp + "/discriminator"});
+} else if (typeof v["type"] !== "string") {
+ e.push({instancePath: p + "/type", schemaPath: sp + "/discriminator"});
+} else if (v["type"] === "a") {
+ validate_variant_a(v, e, p, sp + "/mapping/a");
+} else if (v["type"] === "b") {
+ validate_variant_b(v, e, p, sp + "/mapping/b");
+} else {
+ e.push({instancePath: p + "/type", schemaPath: sp + "/mapping"});
+}
+```
+
+Each variant validator is a generated Properties check. The discriminator
+tag field is excluded from additional-properties checking and from
+property validation in the variant (it was already validated by the
+discriminator check).
+
+#### Ref
+
+Emit a function call to the generated definition validator:
+
+```javascript
+// "ref": "address"
+validate_address(v, e, p, sp);
+```
+
+Each definition becomes a generated function. The function body is the
+emitted code for the definition's AST node.
+
+### 5.3 Inlining Policy
+
+A code generator SHOULD inline checks for leaf nodes (Type, Enum, Empty)
+directly into their parent's generated code.
+
+A code generator SHOULD emit separate functions for:
+- Each definition (called via Ref).
+- Each Properties or Discrim node that appears as the child of Elements,
+ Values, or other container nodes.
+- Each discriminator variant.
+
+A code generator MUST NOT emit helper functions, type-checking utilities,
+or library imports that are not required by the specific schema being
+compiled.
+
+### 5.4 Recursive Schemas
+
+Recursive refs (a definition that ultimately references itself) are legal
+in RFC 8927. In generated code, this becomes recursive function calls:
+
+```javascript
+// Schema: {"definitions":{"node":{"properties":{"next":{"ref":"node","nullable":true}}}},
+// "ref":"node"}
+function validate_node(v, e, p, sp) {
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
+ e.push({instancePath: p, schemaPath: sp});
+ return;
+ }
+ if (!("next" in v)) {
+ e.push({instancePath: p, schemaPath: sp + "/properties/next"});
+ } else if (v["next"] !== null) {
+ validate_node(v["next"], e, p + "/next", sp + "/properties/next");
+ }
+}
+
+export function validate(instance) {
+ const e = [];
+ validate_node(instance, e, "", "");
+ return e;
+}
+```
+
+The target-language call stack provides the implicit work stack. For most
+real-world schemas, recursion depth is bounded by the document's structure.
+
+### 5.5 Discriminator Tag Exemption
+
+When emitting a variant Properties check inside a discriminator, the
+code generator MUST:
+- Exclude the tag field from additional-properties rejection.
+- Not emit a value check for the tag field (it was already validated
+ as a string by the discriminator check).
+
+This means the generated known-key set in the for-in loop includes the
+tag field name, and no property check is emitted for it.
+
+## 6. Error Format
+
+Errors follow RFC 8927 Section 3.3, which defines error indicators as
+pairs of JSON Pointers:
+
+```
+Error = {
+ instancePath: String, -- JSON Pointer (RFC 6901) into the instance
+ schemaPath: String -- JSON Pointer (RFC 6901) into the schema
+}
+```
+
+The `instancePath` points to the value that failed. The `schemaPath` points
+to the schema keyword that caused the failure.
+
+### 6.1 Schema Path Construction
+
+The schema path is built at generation time and baked into the generated
+code as string literals. Each emission rule appends to the schema path:
+
+| Form | Appended path component(s) |
+|---|---|
+| Type | `/type` |
+| Enum | `/enum` |
+| Elements (type guard) | `/elements` |
+| Elements (child) | `/elements` |
+| Properties (type guard) | `/properties` (or `/optionalProperties` if schema has no `properties` member) |
+| Properties (missing key) | `/properties/` |
+| Properties (additional) | (nothing -- error at current path) |
+| Properties (child req) | `/properties/` |
+| Properties (child opt) | `/optionalProperties/` |
+| Values (type guard) | `/values` |
+| Values (child) | `/values` |
+| Discrim (not object) | `/discriminator` |
+| Discrim (tag missing) | `/discriminator` |
+| Discrim (tag not string) | `/discriminator` |
+| Discrim (tag not in map) | `/mapping` |
+| Discrim (variant) | `/mapping/` |
+
+Schema paths are string literals in the generated code. They do not change
+at runtime.
+
+### 6.2 Instance Path Construction
+
+Instance paths are built at runtime via string concatenation:
+
+| Descent into | Appended to instancePath |
+|---|---|
+| Array element at index `i` | `"/" + i` |
+| Object property with key `k` | `"/" + k` |
+| Discriminator tag value | `"/" + tagFieldName` |
+| Discriminator variant | (nothing -- same object) |
+| Ref target | (nothing -- transparent) |
+
+## 7. Conformance
+
+Generated code conforms to this spec if:
+
+1. For any valid RFC 8927 schema and any JSON instance, the generated
+ `validate(instance)` function returns the same set of
+ `(instancePath, schemaPath)` error pairs that RFC 8927 Section 3.3
+ specifies.
+
+2. The generated code passes the official JTD validation test suite
+ (`validation.json` from `json-typedef-spec`) when used as the
+ validation engine.
+
+3. The code generator rejects invalid schemas at generation time per the
+ constraints in Section 3.4.
+
+4. The generated code contains no dead code: no helper functions, loops,
+ branches, or checks that the schema does not require.
+
+5. Validation does not short-circuit. All errors are collected in a
+ single pass.
+
+## 8. Worked Example
+
+Schema:
+```json
+{
+ "properties": {
+ "name": { "type": "string" },
+ "age": { "type": "uint8" },
+ "tags": { "elements": { "type": "string" } }
+ },
+ "optionalProperties": {
+ "email": { "type": "string" }
+ }
+}
+```
+
+### Compiled AST (intermediate, discarded after emission)
+
+```
+Properties {
+ required: {
+ "name" -> Type { type: "string" },
+ "age" -> Type { type: "uint8" },
+ "tags" -> Elements { schema: Type { type: "string" } }
+ },
+ optional: {
+ "email" -> Type { type: "string" }
+ },
+ additional: false
+}
+```
+
+### Generated Code (JavaScript ES2020)
+
+```javascript
+export function validate(instance) {
+ const e = [];
+ if (instance === null || typeof instance !== "object" || Array.isArray(instance)) {
+ e.push({instancePath: "", schemaPath: "/properties"});
+ return e;
+ }
+
+ if (!("name" in instance)) e.push({instancePath: "", schemaPath: "/properties/name"});
+ else if (typeof instance["name"] !== "string")
+ e.push({instancePath: "/name", schemaPath: "/properties/name/type"});
+
+ if (!("age" in instance)) e.push({instancePath: "", schemaPath: "/properties/age"});
+ else {
+ const v = instance["age"];
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255)
+ e.push({instancePath: "/age", schemaPath: "/properties/age/type"});
+ }
+
+ if (!("tags" in instance)) e.push({instancePath: "", schemaPath: "/properties/tags"});
+ else if (!Array.isArray(instance["tags"]))
+ e.push({instancePath: "/tags", schemaPath: "/properties/tags/elements"});
+ else {
+ const arr = instance["tags"];
+ for (let i = 0; i < arr.length; i++) {
+ if (typeof arr[i] !== "string")
+ e.push({instancePath: "/tags/" + i, schemaPath: "/properties/tags/elements/type"});
+ }
+ }
+
+ if ("email" in instance && typeof instance["email"] !== "string")
+ e.push({instancePath: "/email", schemaPath: "/optionalProperties/email/type"});
+
+ for (const k in instance) {
+ if (k !== "name" && k !== "age" && k !== "tags" && k !== "email")
+ e.push({instancePath: "/" + k, schemaPath: ""});
+ }
+
+ return e;
+}
+```
+
+No helper functions. No dead code. Every line corresponds to a specific
+constraint in the schema.
+
+### Validation of example instance
+
+Instance:
+```json
+{ "name": "Alice", "age": 300, "tags": ["a", 42], "extra": true }
+```
+
+Errors produced:
+```json
+[
+ { "instancePath": "/age", "schemaPath": "/properties/age/type" },
+ { "instancePath": "/tags/1", "schemaPath": "/properties/tags/elements/type" },
+ { "instancePath": "/extra", "schemaPath": "" }
+]
+```
+
+- `age`: 300 is a number with zero fractional part, but 300 > 255 (uint8 max).
+- `tags/1`: 42 is not a string.
+- `extra`: not in required or optional properties, and `additionalProperties`
+ defaults to `false`.
diff --git a/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md b/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md
new file mode 100644
index 0000000..c942811
--- /dev/null
+++ b/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md
@@ -0,0 +1,792 @@
+# JTD Stack-Machine Interpreter Specification
+
+A language-independent specification for compiling RFC 8927 JSON Type Definition
+schemas into an immutable AST, and validating JSON documents against that AST
+using an explicit work stack.
+
+This spec describes an **interpreter**: a generic validation engine that walks
+a compiled AST at runtime. The AST is built once, then reused to validate
+any number of JSON documents.
+
+## 1. Terminology
+
+| Term | Meaning |
+|---|---|
+| **schema** | A JSON object conforming to RFC 8927. |
+| **instance** | The JSON value being validated. |
+| **form** | One of the 8 mutually-exclusive schema shapes defined in RFC 8927 plus the nullable modifier. |
+| **AST node** | An immutable, tagged value representing one compiled schema form. |
+| **frame** | A tuple of (AST node, instance, path state) representing one unit of work. |
+| **work stack** | A LIFO collection of frames. Validation is complete when the stack is empty. |
+| **error** | A pair of JSON Pointers: `(instancePath, schemaPath)`. |
+| **definitions** | A flat string-keyed map of named AST nodes, resolved at compile time. |
+| **root** | A compiled schema document: its top-level AST node plus its definitions map. |
+
+## 2. Compile Phase
+
+### 2.1 Input
+
+A JSON object (the schema) and, optionally, a base URI for multi-root
+resolution (see Section 7).
+
+### 2.2 Output
+
+An immutable **Root**:
+
+```
+Root = {
+ schema: Node,
+ definitions: Map -- immutable, keyed by definition name
+}
+```
+
+### 2.3 AST Node Types
+
+A Node is a tagged union (sum type / sealed interface / discriminated union)
+with exactly 9 variants. Implementations MUST represent these as immutable
+value types (records, data classes, frozen structs, etc.).
+
+```
+Node =
+ | Empty -- {}
+ | Ref { name: String } -- {"ref": "..."}
+ | Type { type: TypeKeyword } -- {"type": "..."}
+ | Enum { values: List } -- {"enum": [...]}
+ | Elements { schema: Node } -- {"elements": ...}
+ | Properties { required: Map, -- {"properties": ...}
+ optional: Map, -- {"optionalProperties": ...}
+ additional: Boolean } -- {"additionalProperties": ...}
+ | Values { schema: Node } -- {"values": ...}
+ | Discrim { tag: String, mapping: Map} -- {"discriminator":...,"mapping":...}
+ | Nullable { inner: Node } -- any form + "nullable": true
+```
+
+`TypeKeyword` is one of the 12 strings defined in RFC 8927 Section 2.2.3:
+
+```
+TypeKeyword = boolean | string | timestamp
+ | int8 | uint8 | int16 | uint16 | int32 | uint32
+ | float32 | float64
+```
+
+### 2.4 Compilation Algorithm
+
+```
+compile(json, isRoot=true, definitions) -> Node:
+
+ REQUIRE json is a JSON object
+
+ IF isRoot:
+ IF json has key "definitions":
+ REQUIRE json["definitions"] is a JSON object
+ -- Pass 1: register all keys as placeholders for forward refs
+ FOR EACH key in json["definitions"]:
+ definitions[key] = PLACEHOLDER
+ -- Pass 2: compile each definition
+ FOR EACH key in json["definitions"]:
+ definitions[key] = compile(json["definitions"][key], isRoot=false, definitions)
+ ELSE:
+ REQUIRE json does NOT have key "definitions"
+
+ -- Detect form
+ forms = []
+ IF json has "ref": forms += "ref"
+ IF json has "type": forms += "type"
+ IF json has "enum": forms += "enum"
+ IF json has "elements": forms += "elements"
+ IF json has "values": forms += "values"
+ IF json has "discriminator": forms += "discriminator"
+ IF json has "properties" OR json has "optionalProperties":
+ forms += "properties"
+
+ REQUIRE |forms| <= 1
+
+ -- Compile form
+ node = MATCH forms:
+ [] -> Empty
+ ["ref"] -> compileRef(json, definitions)
+ ["type"] -> compileType(json)
+ ["enum"] -> compileEnum(json)
+ ["elements"] -> compileElements(json, definitions)
+ ["properties"] -> compileProperties(json, definitions)
+ ["values"] -> compileValues(json, definitions)
+ ["discriminator"]-> compileDiscriminator(json, definitions)
+
+ -- Nullable modifier wraps any form
+ IF json has "nullable" AND json["nullable"] == true:
+ node = Nullable { inner: node }
+
+ RETURN node
+```
+
+### 2.5 Form-Specific Compilation
+
+**Ref**:
+```
+compileRef(json, definitions):
+ name = json["ref"] -- must be a string
+ REQUIRE name IN definitions -- forward refs are valid (placeholder exists)
+ RETURN Ref { name }
+```
+
+**Type**:
+```
+compileType(json):
+ t = json["type"] -- must be a string
+ REQUIRE t IN TypeKeyword
+ RETURN Type { type: t }
+```
+
+**Enum**:
+```
+compileEnum(json):
+ values = json["enum"] -- must be a non-empty array of strings
+ REQUIRE no duplicates in values
+ RETURN Enum { values }
+```
+
+**Elements**:
+```
+compileElements(json, definitions):
+ inner = compile(json["elements"], isRoot=false, definitions)
+ RETURN Elements { schema: inner }
+```
+
+**Properties**:
+```
+compileProperties(json, definitions):
+ req = {}
+ opt = {}
+ IF json has "properties":
+ FOR EACH (key, schema) in json["properties"]:
+ req[key] = compile(schema, isRoot=false, definitions)
+ IF json has "optionalProperties":
+ FOR EACH (key, schema) in json["optionalProperties"]:
+ opt[key] = compile(schema, isRoot=false, definitions)
+ REQUIRE keys(req) INTERSECT keys(opt) == {}
+ additional = json.get("additionalProperties", false)
+ RETURN Properties { required: req, optional: opt, additional }
+```
+
+**Values**:
+```
+compileValues(json, definitions):
+ inner = compile(json["values"], isRoot=false, definitions)
+ RETURN Values { schema: inner }
+```
+
+**Discriminator**:
+```
+compileDiscriminator(json, definitions):
+ tag = json["discriminator"] -- must be a string
+ REQUIRE json has "mapping"
+ mapping = {}
+ FOR EACH (key, schema) in json["mapping"]:
+ node = compile(schema, isRoot=false, definitions)
+ REQUIRE node is Properties -- not Nullable, not any other form
+ REQUIRE tag NOT IN node.required
+ REQUIRE tag NOT IN node.optional
+ mapping[key] = node
+ RETURN Discrim { tag, mapping }
+```
+
+### 2.6 Compile-Time Invariants
+
+After compilation, the following are guaranteed:
+- Every `Ref.name` resolves to an entry in `definitions`.
+- Every `Discrim.mapping` value is a `Properties` node (not nullable).
+- No `Properties` node has overlapping required/optional keys.
+- The AST is immutable. No node is modified after construction.
+
+## 3. Runtime Phase: The Work Stack
+
+### 3.1 Data Structures
+
+**Frame** -- one unit of pending work:
+```
+Frame = {
+ node: Node, -- which AST node to validate against
+ instance: JsonValue, -- which piece of the document to inspect
+ instancePath: String, -- JSON Pointer into the document (e.g. "/foo/0/bar")
+ schemaPath: String, -- JSON Pointer into the schema (e.g. "/properties/foo")
+ discriminatorTag: String? -- carried from Discrim to variant Properties
+}
+```
+
+**Error** -- one validation failure:
+```
+Error = {
+ instancePath: String, -- JSON Pointer into the document
+ schemaPath: String -- JSON Pointer into the schema
+}
+```
+
+**State** -- the complete validation state:
+```
+State = {
+ stack: Stack, -- LIFO work stack
+ errors: List, -- accumulated errors
+ definitions: Map -- from the compiled Root
+}
+```
+
+### 3.2 Main Loop
+
+```
+validate(root: Root, instance: JsonValue) -> List:
+ state = {
+ stack: [ Frame(root.schema, instance, "", "", null) ],
+ errors: [],
+ definitions: root.definitions
+ }
+
+ WHILE state.stack is not empty:
+ frame = state.stack.pop()
+ step(frame, state)
+
+ RETURN state.errors
+```
+
+The loop is iterative. There is no recursion. The stack depth is bounded by
+the document's structural depth, not by the schema's complexity. This
+prevents stack overflow on deeply nested documents.
+
+Validation does **not** short-circuit. All frames are processed. All errors
+are collected.
+
+### 3.3 The Step Function
+
+```
+step(frame, state):
+ node = frame.node
+
+ -- Nullable check: intercepts before any form logic
+ IF node is Nullable:
+ IF frame.instance is null:
+ RETURN -- null is valid; nothing to push
+ ELSE:
+ -- Unwrap and re-step with the inner node
+ step(Frame(node.inner, frame.instance, frame.instancePath,
+ frame.schemaPath, frame.discriminatorTag), state)
+ RETURN
+
+ MATCH node:
+ Empty -> pass(frame, state)
+ Ref -> stepRef(frame, state)
+ Type -> stepType(frame, state)
+ Enum -> stepEnum(frame, state)
+ Elements -> stepElements(frame, state)
+ Properties -> stepProperties(frame, state)
+ Values -> stepValues(frame, state)
+ Discrim -> stepDiscriminator(frame, state)
+```
+
+## 4. Step Functions
+
+Each step function validates the current level, then pushes child frames
+for descent. If the current level fails its type guard, no children are
+pushed.
+
+### 4.1 Empty
+
+```
+pass(frame, state):
+ -- Accept anything. Push nothing.
+```
+
+### 4.2 Ref
+
+```
+stepRef(frame, state):
+ target = state.definitions[frame.node.name]
+ -- Replace the Ref node with its target and re-step.
+ -- The frame's paths are preserved (the ref is transparent).
+ step(Frame(target, frame.instance, frame.instancePath,
+ frame.schemaPath, frame.discriminatorTag), state)
+```
+
+Recursive refs (a definition that references itself) are legal in RFC 8927.
+This works naturally because each ref resolution pushes work onto the stack
+(indirectly, via the resolved node's step function). The explicit stack
+bounds memory usage to the document's depth, not the schema's recursion
+depth.
+
+### 4.3 Type
+
+```
+stepType(frame, state):
+ ok = MATCH frame.node.type:
+ "boolean" -> frame.instance is a JSON boolean
+ "string" -> frame.instance is a JSON string
+ "timestamp" -> frame.instance is a JSON string
+ AND matches RFC 3339 (with leap-second normalization)
+ "float32" -> frame.instance is a JSON number
+ "float64" -> frame.instance is a JSON number
+ "int8" -> isIntInRange(frame.instance, -128, 127)
+ "uint8" -> isIntInRange(frame.instance, 0, 255)
+ "int16" -> isIntInRange(frame.instance, -32768, 32767)
+ "uint16" -> isIntInRange(frame.instance, 0, 65535)
+ "int32" -> isIntInRange(frame.instance, -2147483648, 2147483647)
+ "uint32" -> isIntInRange(frame.instance, 0, 4294967295)
+
+ IF NOT ok:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/type")
+```
+
+Helper:
+```
+isIntInRange(value, min, max):
+ REQUIRE value is a JSON number
+ REQUIRE value has zero fractional part (e.g. 3.0 is integer, 3.5 is not)
+ REQUIRE min <= value <= max
+```
+
+### 4.4 Enum
+
+```
+stepEnum(frame, state):
+ IF frame.instance is NOT a JSON string:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/enum")
+ RETURN
+
+ IF frame.instance.stringValue NOT IN frame.node.values:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/enum")
+```
+
+### 4.5 Elements
+
+```
+stepElements(frame, state):
+ IF frame.instance is NOT a JSON array:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/elements")
+ RETURN
+
+ -- Push one child frame per array element
+ FOR i = 0 TO length(frame.instance) - 1:
+ child = Frame(
+ node: frame.node.schema,
+ instance: frame.instance[i],
+ instancePath: frame.instancePath + "/" + str(i),
+ schemaPath: frame.schemaPath + "/elements",
+ discriminatorTag: null
+ )
+ state.stack.push(child)
+```
+
+### 4.6 Properties
+
+This is the only form with three concerns: missing-key checks, child
+descent, and additional-key rejection.
+
+```
+stepProperties(frame, state):
+ IF frame.instance is NOT a JSON object:
+ -- RFC 8927 §3.3.6: point to "properties" if it exists, else "optionalProperties"
+ LET guardKey = "properties" IF frame.node.required is non-empty ELSE "optionalProperties"
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/" + guardKey)
+ RETURN
+
+ obj = frame.instance
+
+ -- 1. Missing required properties
+ FOR EACH (key, _) IN frame.node.required:
+ IF key NOT IN obj:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/properties/" + key)
+
+ -- 2. Additional properties check
+ IF NOT frame.node.additional:
+ FOR EACH key IN keys(obj):
+ IF key NOT IN frame.node.required
+ AND key NOT IN frame.node.optional
+ AND key != frame.discriminatorTag: -- discriminator tag exemption
+ state.errors += Error(frame.instancePath + "/" + key, frame.schemaPath)
+
+ -- 3. Push child frames for required properties (if present in instance)
+ FOR EACH (key, childNode) IN frame.node.required:
+ IF key == frame.discriminatorTag: SKIP -- already validated by Discrim
+ IF key IN obj:
+ state.stack.push(Frame(
+ node: childNode,
+ instance: obj[key],
+ instancePath: frame.instancePath + "/" + key,
+ schemaPath: frame.schemaPath + "/properties/" + key,
+ discriminatorTag: null
+ ))
+
+ -- 4. Push child frames for optional properties (if present in instance)
+ FOR EACH (key, childNode) IN frame.node.optional:
+ IF key == frame.discriminatorTag: SKIP
+ IF key IN obj:
+ state.stack.push(Frame(
+ node: childNode,
+ instance: obj[key],
+ instancePath: frame.instancePath + "/" + key,
+ schemaPath: frame.schemaPath + "/optionalProperties/" + key,
+ discriminatorTag: null
+ ))
+```
+
+### 4.7 Values
+
+```
+stepValues(frame, state):
+ IF frame.instance is NOT a JSON object:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/values")
+ RETURN
+
+ FOR EACH (key, value) IN frame.instance:
+ state.stack.push(Frame(
+ node: frame.node.schema,
+ instance: value,
+ instancePath: frame.instancePath + "/" + key,
+ schemaPath: frame.schemaPath + "/values",
+ discriminatorTag: null
+ ))
+```
+
+### 4.8 Discriminator
+
+The discriminator form is a 5-step sequential check. If any step fails,
+no child frames are pushed.
+
+```
+stepDiscriminator(frame, state):
+ -- Step 1: Must be an object
+ IF frame.instance is NOT a JSON object:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/discriminator")
+ RETURN
+
+ obj = frame.instance
+
+ -- Step 2: Tag property must exist
+ IF frame.node.tag NOT IN obj:
+ state.errors += Error(frame.instancePath, frame.schemaPath + "/discriminator")
+ RETURN
+
+ tagValue = obj[frame.node.tag]
+
+ -- Step 3: Tag must be a string
+ IF tagValue is NOT a JSON string:
+ state.errors += Error(frame.instancePath + "/" + frame.node.tag,
+ frame.schemaPath + "/discriminator")
+ RETURN
+
+ tagString = tagValue.stringValue
+
+ -- Step 4: Tag value must be in mapping
+ IF tagString NOT IN frame.node.mapping:
+ state.errors += Error(frame.instancePath + "/" + frame.node.tag,
+ frame.schemaPath + "/mapping")
+ RETURN
+
+ -- Step 5: Push variant frame with discriminator tag exemption
+ variantNode = frame.node.mapping[tagString]
+ state.stack.push(Frame(
+ node: variantNode,
+ instance: obj, -- same object, NOT the tag value
+ instancePath: frame.instancePath,
+ schemaPath: frame.schemaPath + "/mapping/" + tagString,
+ discriminatorTag: frame.node.tag -- passed to Properties for exemption
+ ))
+```
+
+## 5. Type Checking Reference
+
+Exact semantics for each `TypeKeyword`.
+
+### 5.1 boolean
+
+```
+value is a JSON boolean (true or false)
+```
+
+### 5.2 string
+
+```
+value is a JSON string
+```
+
+### 5.3 timestamp
+
+```
+value is a JSON string
+AND value matches the RFC 3339 date-time production
+ (regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}|60)(\.\d+)?(Z|[+-]\d{2}:\d{2})$)
+AND the date-time is parseable (accounting for leap seconds by
+ normalizing :60 to :59 before parsing)
+```
+
+### 5.4 float32, float64
+
+```
+value is a JSON number (any finite number; no range check)
+```
+
+Note: RFC 8927 does not distinguish float32 from float64 at the validation
+level. Both accept any JSON number.
+
+### 5.5 Integer types
+
+All integer types share the same two-step check:
+
+```
+value is a JSON number
+AND value has zero fractional part (floor(value) == value)
+AND value is within the type's range (inclusive)
+```
+
+| Type | Min | Max |
+|---|---|---|
+| int8 | -128 | 127 |
+| uint8 | 0 | 255 |
+| int16 | -32768 | 32767 |
+| uint16 | 0 | 65535 |
+| int32 | -2147483648 | 2147483647 |
+| uint32 | 0 | 4294967295 |
+
+Note: `3.0` is a valid int8. `3.5` is not. This is value-based, not
+syntax-based.
+
+## 6. Multi-Root Support
+
+RFC 8927 defines a single-document schema. This section extends it to
+support multiple schema documents that reference each other.
+
+### 6.1 Root Registry
+
+```
+Roots = Map -- immutable after compilation
+```
+
+### 6.2 Compile-Time Resolution
+
+Compilation uses a LIFO work stack of document URIs:
+
+```
+compileAll(initialJson, initialUri) -> Roots:
+ work = Stack
+ built = Map
+
+ work.push(normalize(initialUri))
+
+ WHILE work is not empty:
+ uri = work.pop()
+ IF uri IN built: CONTINUE -- dedup: at-most-once per URI
+
+ json = fetch(uri, initialJson) -- fetch or use initialJson if uri matches
+ root = compileRoot(json)
+
+ -- Scan for remote refs
+ FOR EACH Ref node in root.schema (recursively):
+ targetUri = resolveUri(node.name, uri)
+ IF targetUri.document != uri AND targetUri.document NOT IN built:
+ work.push(targetUri.document)
+
+ built[uri] = root
+
+ RETURN freeze(built) -- immutable
+```
+
+### 6.3 Runtime with Multiple Roots
+
+In the initial implementation, runtime validation uses only the primary root.
+Remote refs are compiled and stored but not traversed at runtime. This
+preserves compatibility with single-document behavior.
+
+Future extensions resolve remote refs at runtime by looking up the target
+root in the Roots registry and pushing frames that reference nodes from
+different roots.
+
+## 7. Error Format
+
+Errors follow RFC 8927 Section 3.3, which defines error indicators as
+pairs of JSON Pointers:
+
+```
+Error = {
+ instancePath: String, -- JSON Pointer (RFC 6901) into the instance
+ schemaPath: String -- JSON Pointer (RFC 6901) into the schema
+}
+```
+
+The `instancePath` points to the value that failed. The `schemaPath` points
+to the schema keyword that caused the failure.
+
+Implementations MAY enrich errors with additional information (character
+offsets, human-readable breadcrumb trails, etc.) but MUST always include
+the two JSON Pointer fields.
+
+### 7.1 Schema Path Construction
+
+Each step function appends to `schemaPath` as it descends:
+
+| Form | Appended path component(s) |
+|---|---|
+| Type | `/type` |
+| Enum | `/enum` |
+| Elements (type guard) | `/elements` |
+| Elements (child) | `/elements` |
+| Properties (type guard) | `/properties` (or `/optionalProperties` if schema has no `properties` member) |
+| Properties (missing key) | `/properties/` |
+| Properties (additional) | (nothing -- error at current path) |
+| Properties (child req) | `/properties/` |
+| Properties (child opt) | `/optionalProperties/` |
+| Values (type guard) | `/values` |
+| Values (child) | `/values` |
+| Discrim (not object) | `/discriminator` |
+| Discrim (tag missing) | `/discriminator` |
+| Discrim (tag not string) | `/discriminator` |
+| Discrim (tag not in map) | `/mapping` |
+| Discrim (variant) | `/mapping/` |
+
+### 7.2 Instance Path Construction
+
+| Descent into | Appended to instancePath |
+|---|---|
+| Array element at index `i` | `/` |
+| Object property with key `k` | `/` |
+| Discriminator tag value | `/` |
+| Discriminator variant | (nothing -- same object) |
+| Ref target | (nothing -- transparent) |
+
+## 8. Implementation Notes
+
+### 8.1 Stack Ordering
+
+The work stack is LIFO (depth-first). The last child pushed is the first
+validated. For properties, this means children are validated in reverse
+insertion order. The error *set* is the same regardless of stack ordering;
+only the error *order* may differ. RFC 8927 does not specify error ordering.
+
+### 8.2 Discriminator Tag Exemption
+
+When a Discriminator pushes a variant Properties frame, it passes the tag
+field name as `discriminatorTag`. The Properties step function uses this to:
+- Skip the tag field when checking additional properties.
+- Skip the tag field when pushing child frames (it was already validated
+ by the Discriminator step).
+
+This is the only case where state flows between step functions.
+
+### 8.3 No Short-Circuit
+
+Validation processes all frames even after encountering errors. This
+ensures all errors are reported in a single pass. An implementation MAY
+offer an optional `maxErrors` parameter to bound error collection.
+
+### 8.4 Immutability
+
+The AST is immutable after compilation. The Frame is immutable. The only
+mutable state during validation is the work stack and the error list.
+This makes the validation loop trivially thread-safe since the stack and
+error list are created per `validate()` call.
+
+### 8.5 Memory
+
+Each frame is a small tuple (5 fields). Stack depth equals document nesting
+depth. Memory is O(depth * breadth) in the worst case (a wide object with
+many properties each needing validation).
+
+The explicit stack replaces the language's call stack, preventing stack
+overflow on deeply nested or recursive documents.
+
+## 9. Conformance
+
+An implementation conforms to this spec if:
+
+1. It compiles any valid RFC 8927 schema into the AST defined in Section 2.3.
+2. It rejects invalid schemas at compile time per the constraints in Section 2.6.
+3. It validates any JSON instance against a compiled AST using the step
+ functions defined in Section 4, producing the error paths defined in
+ Section 7.
+4. It passes the official JTD validation test suite (`validation.json` from
+ `json-typedef-spec`).
+5. It passes the official JTD invalid schema test suite (`invalid_schemas.json`).
+
+## 10. Worked Example
+
+Schema:
+```json
+{
+ "properties": {
+ "name": { "type": "string" },
+ "age": { "type": "uint8" },
+ "tags": { "elements": { "type": "string" } }
+ },
+ "optionalProperties": {
+ "email": { "type": "string" }
+ }
+}
+```
+
+Instance:
+```json
+{
+ "name": "Alice",
+ "age": 300,
+ "tags": ["a", 42],
+ "extra": true
+}
+```
+
+### Compiled AST
+
+```
+Properties {
+ required: {
+ "name" -> Type { type: "string" },
+ "age" -> Type { type: "uint8" },
+ "tags" -> Elements { schema: Type { type: "string" } }
+ },
+ optional: {
+ "email" -> Type { type: "string" }
+ },
+ additional: false
+}
+```
+
+### Validation Trace
+
+```
+Stack: [Frame(Properties, root, "", "")]
+Pop: Frame(Properties, root, "", "")
+ -> Type guard: root is object? YES
+ -> Missing keys: all 3 required keys present? YES
+ -> Additional keys: "extra" not in required/optional -> ERROR("/extra", "")
+ -> Push children:
+ Frame(Type("string"), "Alice", "/name", "/properties/name")
+ Frame(Type("uint8"), 300, "/age", "/properties/age")
+ Frame(Elements, [...], "/tags", "/properties/tags")
+
+Stack: [Elements, Type("uint8"), Type("string")]
+Pop: Frame(Elements, ["a",42], "/tags", "/properties/tags")
+ -> Type guard: is array? YES
+ -> Push children:
+ Frame(Type("string"), "a", "/tags/0", "/properties/tags/elements")
+ Frame(Type("string"), 42, "/tags/1", "/properties/tags/elements")
+
+Stack: [Type("string")/42, Type("string")/"a", Type("uint8"), Type("string")]
+Pop: Frame(Type("string"), 42, "/tags/1", "/properties/tags/elements")
+ -> 42 is not a string -> ERROR("/tags/1", "/properties/tags/elements/type")
+
+Pop: Frame(Type("string"), "a", "/tags/0", "/properties/tags/elements")
+ -> "a" is a string -> OK
+
+Pop: Frame(Type("uint8"), 300, "/age", "/properties/age")
+ -> 300 is number, zero fractional, but 300 > 255 -> ERROR("/age", "/properties/age/type")
+
+Pop: Frame(Type("string"), "Alice", "/name", "/properties/name")
+ -> "Alice" is a string -> OK
+
+Stack empty. Done.
+```
+
+### Errors Collected
+
+```
+[
+ { instancePath: "/extra", schemaPath: "" },
+ { instancePath: "/tags/1", schemaPath: "/properties/tags/elements/type" },
+ { instancePath: "/age", schemaPath: "/properties/age/type" }
+]
+```
diff --git a/json-java21-jtd/PROBE_TESTS_SUMMARY.md b/json-java21-jtd/PROBE_TESTS_SUMMARY.md
new file mode 100644
index 0000000..5617786
--- /dev/null
+++ b/json-java21-jtd/PROBE_TESTS_SUMMARY.md
@@ -0,0 +1,209 @@
+# Probe Tests Summary
+
+This document summarizes all the probe tests added to identify potential issues in the JTD implementation.
+
+## Test Files Added
+
+### 1. `ErrorFormatComplianceProbe.java`
+**Purpose**: Verify RFC 8927 error format compliance
+
+**Key Probes**:
+- Error format should be `{instancePath, schemaPath}` pairs (not enriched strings)
+- Schema paths should point to schema keywords (`/type`, `/properties/foo`, etc.)
+- Instance paths should be RFC 6901 JSON Pointers
+- Multiple errors should ALL be collected
+- Error consistency across different violations
+
+**Expected Issues to Find**:
+- Implementation returns enriched strings like `[off=N ptr=# via=#] message`
+- No RFC 6901 compliance for instance paths
+- Schema paths not tracked separately
+
+### 2. `DiscriminatorEdgeCaseProbe.java`
+**Purpose**: Test discriminator schema edge cases
+
+**Key Probes**:
+- Missing vs non-string discriminator error distinction
+- Discriminator tag exemption from additionalProperties
+- Discriminator key redefinition in properties (compile-time check)
+- Discriminator key in optionalProperties (compile-time check)
+- Empty discriminator mapping
+- Discriminator with additionalProperties: true
+- Nested discriminator
+- Discriminator error short-circuiting
+- Discriminator with null/empty string values
+- Multiple discriminator values with common properties
+- Conflicting property types across variants
+
+**Expected Issues to Find**:
+- Missing vs non-string discriminator errors may be conflated
+- Discriminator key not properly exempted from additionalProperties
+- Variant validation may continue after discriminator error
+
+### 3. `PropertiesEdgeCaseProbe.java`
+**Purpose**: Test properties form edge cases
+
+**Key Probes**:
+- Empty properties with additionalProperties: false
+- Empty properties without additionalProperties (default behavior)
+- Special characters in property names (dots, slashes, spaces, null char)
+- Multiple additional properties all reported
+- Required property with null value vs missing property
+- Optional property with various values
+- Overlapping required and optional keys (compile-time check)
+- Nested properties with different additionalProperties settings
+- Property validation order
+- Empty string property names
+- Very deep nesting (50 levels)
+- Large number of properties (100)
+- Property name collisions with prototype pollution concerns
+
+**Expected Issues to Find**:
+- Empty properties may not properly reject additional properties
+- Special characters in property names may not be handled correctly
+- Deep nesting performance issues
+- Prototype pollution vulnerability
+
+### 4. `TypeValidationEdgeCaseProbe.java`
+**Purpose**: Test type validation edge cases
+
+**Key Probes**:
+- Integer boundary values (exact min/max for all integer types)
+- Integer fractional detection (3.0 is int, 3.1 is not)
+- Scientific notation handling
+- Float types accept any number
+- Timestamp format variations
+- Invalid timestamp formats
+- Boolean type strictness
+- String type strictness
+- Very large integers
+- Zero values for all integer types
+- Negative values for unsigned types
+- No type coercion
+- BigDecimal values exceeding long precision
+
+**Expected Issues to Find**:
+- Integer validation may not properly handle fractional values
+- Scientific notation may not be handled correctly
+- Timestamp regex may be too strict or too lenient
+- Very large numbers may cause precision issues
+
+### 5. `RefEdgeCaseProbe.java`
+**Purpose**: Test ref schema edge cases
+
+**Key Probes**:
+- Forward reference resolution
+- Mutual recursion
+- Deeply nested refs (50 levels)
+- Ref in elements context
+- Ref in values context
+- Ref in properties context
+- Ref to empty schema
+- Ref to discriminator
+- Ref to nullable schema
+- Ref to elements/values/properties/enum/type
+- Unused definitions
+- Multiple refs to same definition
+- Complex recursive ref (binary tree)
+- Ref in optionalProperties
+- Multi-level ref resolution
+
+**Expected Issues to Find**:
+- Forward references may not resolve correctly
+- Deep nesting may cause stack issues
+- Circular references may not be handled
+
+### 6. `ElementsEdgeCaseProbe.java`
+**Purpose**: Test elements form edge cases
+
+**Key Probes**:
+- Empty array validation
+- Single element array
+- Nested elements (2D/3D arrays)
+- Elements with properties schema
+- Elements with discriminator
+- Elements error collection (all elements validated)
+- Elements with strict nested objects
+- Large array performance (1000 elements)
+- Array with null elements
+- Array with nullable elements
+- Mixed valid and invalid elements
+- Empty schema in elements
+- Elements with ref
+- Elements error path construction
+- Nested elements error path
+- Elements with object additional properties
+- Multiple arrays in same schema
+- Elements with values schema
+- Elements with enum
+- Sparse array
+- Elements with complex nested structure
+- Array type guard
+- Elements with timestamp/boolean/float types
+- Elements with integer boundaries
+
+**Expected Issues to Find**:
+- Error path construction for nested arrays
+- Large array performance
+- Error collection for all invalid elements
+
+### 7. `NullableEdgeCaseProbe.java`
+**Purpose**: Test nullable modifier edge cases
+
+**Key Probes**:
+- Nullable type accepts null
+- Non-nullable type rejects null
+- Nullable explicit false
+- Nullable on empty schema
+- Nullable on enum/elements/properties/values/discriminator/ref
+- Nested nullable
+- Nullable property value
+- Nullable required property
+- Nullable optional property
+- Nullable array element
+- Nullable values in object
+- Nullable with all integer types
+- Nullable with float types
+- Nullable with timestamp/boolean
+- Nullable error messages
+- Nullable must be boolean compilation check
+- Nullable in definitions
+- Nullable with complex nested schema
+- Nullable discriminator mapping value (compile-time check)
+- Multiple nullable fields
+- Nullable with additionalProperties
+
+**Expected Issues to Find**:
+- Nullable discriminator mapping may not be rejected at compile time
+- Nullable compilation may accept non-boolean values
+
+## Running the Tests
+
+```bash
+# Run all probe tests
+$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest="*Probe"
+
+# Run specific probe test
+$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest=ErrorFormatComplianceProbe
+
+# Run with detailed logging
+$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest="*Probe" -Djava.util.logging.ConsoleHandler.level=FINE
+```
+
+## Expected Outcomes
+
+These tests are designed to **document current behavior** and **expose deviations** from the specification. They do not fix any code.
+
+Key deviations expected:
+1. **Error format**: Returns enriched strings instead of RFC 8927 error indicators
+2. **Schema paths**: Not tracked or reported per RFC 8927
+3. **Discriminator**: May conflate missing vs non-string discriminator errors
+4. **Empty properties**: May not properly reject additional properties by default
+5. **Nullable discriminator mapping**: May not be rejected at compile time
+
+## Notes
+
+- All probe tests extend `JtdTestBase` for consistent logging
+- Tests use INFO level logging at method start as per AGENTS.md
+- Tests document both passing and failing assertions
+- Tests include comments explaining the expected vs actual behavior
diff --git a/json-java21-jtd/README.md b/json-java21-jtd/README.md
index f034c7a..ca901dc 100644
--- a/json-java21-jtd/README.md
+++ b/json-java21-jtd/README.md
@@ -186,6 +186,62 @@ Validation errors include standardized information:
./mvnw test -pl json-java21-jtd -am -Djava.util.logging.ConsoleHandler.level=FINE
```
+## Functional Validator API
+
+A schema can be compiled into a reusable `JtdValidator` -- a functional interface
+(`JsonValue -> JtdValidationResult`) suitable for stream pipelines:
+
+```java
+import json.java21.jtd.JtdValidator;
+import json.java21.jtd.JtdValidationResult;
+import jdk.sandbox.java.util.json.*;
+
+String schemaJson = """
+ { "type": "string" }
+ """;
+JsonValue schema = Json.parse(schemaJson);
+
+// Compile to a reusable validator (interpreter path, always available)
+JtdValidator validator = JtdValidator.compile(schema);
+
+JtdValidationResult result = validator.validate(Json.parse("\"hello\""));
+assert result.isValid();
+
+// Use in a stream pipeline
+List docs = ...;
+List invalid = docs.stream()
+ .filter(doc -> !validator.validate(doc).isValid())
+ .toList();
+```
+
+Errors follow RFC 8927 exactly -- each error is an `(instancePath, schemaPath)` pair:
+
+```java
+JtdValidationResult result = validator.validate(Json.parse("42"));
+result.errors().forEach(e ->
+ System.out.println(e.instancePath() + " -> " + e.schemaPath()));
+// Output: "" -> "/type"
+```
+
+### Generated Validators (optional, JDK 24+)
+
+When the `json-java21-jtd-codegen` module is on the classpath **and** the build
+runs on JDK 24+, the factory can generate optimised bytecode validators that
+contain only the checks the schema requires -- no interpreter, no AST, no
+runtime stack:
+
+```java
+// Throws if codegen module is not on the classpath
+JtdValidator fast = JtdValidator.compileGenerated(schema);
+```
+
+The generated classfiles target Java 21 (class version 65) so they run on any
+JDK 21+ runtime. The `toString()` of a generated validator returns the original
+JTD schema JSON.
+
+If you do not need the generated path, the interpreter path (`JtdValidator.compile`)
+works everywhere with zero extra dependencies.
+
## Architecture
The validator uses a stack-based approach for efficient validation:
@@ -196,6 +252,8 @@ The validator uses a stack-based approach for efficient validation:
- **Comprehensive Testing**: Full RFC 8927 compliance test suite
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed implementation information.
+See [JTD_STACK_MACHINE_SPEC.md](JTD_STACK_MACHINE_SPEC.md) for the interpreter specification.
+See [JTD_CODEGEN_SPEC.md](JTD_CODEGEN_SPEC.md) for the code generation specification.
## RFC 8927 Compliance
@@ -214,6 +272,7 @@ This implementation is fully compliant with RFC 8927:
- **Stack-based validation** prevents StackOverflowError
- **Early exit** on first validation error
- **Immutable design** enables safe concurrent use
+- **Optional codegen** (JDK 24+ build) eliminates interpreter overhead for hot-path validation
## License
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java b/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java
index a99442c..07d098d 100644
--- a/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java
@@ -2,11 +2,24 @@
import jdk.sandbox.java.util.json.JsonValue;
-/// Stack frame for iterative validation with path and offset tracking
-record Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs, String discriminatorKey) {
- /// Constructor for normal validation without discriminator context
+/// Stack frame for iterative validation with path and offset tracking.
+///
+/// - `ptr` is the JSON Pointer into the **instance** (RFC 6901).
+/// - `schemaPath` is the JSON Pointer into the **schema** (RFC 8927 §3.3).
+/// - `crumbs` is a human-readable breadcrumb trail for the enriched error format.
+/// - `discriminatorKey` carries the tag field name from a Discriminator to its
+/// variant Properties step (see JTD_STACK_MACHINE_SPEC.md §4.8).
+record Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs,
+ String schemaPath, String discriminatorKey) {
+
+ /// Constructor for normal validation without discriminator context or schema path
Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs) {
- this(schema, instance, ptr, crumbs, null);
+ this(schema, instance, ptr, crumbs, "", null);
+ }
+
+ /// Constructor with schema path but no discriminator
+ Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs, String schemaPath) {
+ this(schema, instance, ptr, crumbs, schemaPath, null);
}
@Override
@@ -14,6 +27,6 @@ public String toString() {
final var kind = schema.getClass().getSimpleName();
final var tag = (schema instanceof JtdSchema.RefSchema r) ? "(ref=" + r.ref() + ")" : "";
return "Frame[schema=" + kind + tag + ", instance=" + instance + ", ptr=" + ptr +
- ", crumbs=" + crumbs + ", discriminatorKey=" + discriminatorKey + "]";
+ ", schemaPath=" + schemaPath + ", crumbs=" + crumbs + ", discriminatorKey=" + discriminatorKey + "]";
}
}
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java b/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java
new file mode 100644
index 0000000..eaaea63
--- /dev/null
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java
@@ -0,0 +1,254 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.JsonValue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+/// Stack-machine interpreter wrapped as a [JtdValidator].
+///
+/// Produces RFC 8927 `(instancePath, schemaPath)` error pairs by walking
+/// the compiled [JtdSchema] AST with an explicit work stack.
+final class InterpreterValidator implements JtdValidator {
+
+ private static final Logger LOG = Logger.getLogger(InterpreterValidator.class.getName());
+
+ private final JtdSchema schema;
+ private final String schemaJson;
+
+ InterpreterValidator(JtdSchema schema, Jtd jtd, String schemaJson) {
+ this.schema = schema;
+ this.schemaJson = schemaJson;
+ }
+
+ @Override
+ public JtdValidationResult validate(JsonValue instance) {
+ LOG.fine(() -> "InterpreterValidator validating instance");
+ final var errors = new ArrayList();
+ final var stack = new java.util.ArrayDeque();
+
+ stack.push(new Frame(schema, instance, "", Crumbs.root(), "", null));
+
+ while (!stack.isEmpty()) {
+ final var frame = stack.pop();
+ stepRfc8927(frame, stack, errors);
+ }
+
+ return errors.isEmpty()
+ ? JtdValidationResult.success()
+ : JtdValidationResult.failure(errors);
+ }
+
+ @Override
+ public String toString() {
+ return schemaJson;
+ }
+
+ // ------------------------------------------------------------------
+ // RFC 8927 step function producing (instancePath, schemaPath) pairs
+ // ------------------------------------------------------------------
+
+ private void stepRfc8927(Frame frame, java.util.Deque stack, List errors) {
+ final var node = frame.schema();
+
+ if (node instanceof JtdSchema.NullableSchema nullable) {
+ if (frame.instance() instanceof jdk.sandbox.java.util.json.JsonNull) return;
+ stepRfc8927(new Frame(nullable.wrapped(), frame.instance(), frame.ptr(),
+ frame.crumbs(), frame.schemaPath(), frame.discriminatorKey()), stack, errors);
+ return;
+ }
+
+ switch (node) {
+ case JtdSchema.EmptySchema empty -> { /* accepts anything */ }
+ case JtdSchema.RefSchema ref -> {
+ final var resolved = ref.target();
+ stepRfc8927(new Frame(resolved, frame.instance(), frame.ptr(),
+ frame.crumbs(), "/definitions/" + ref.ref(), frame.discriminatorKey()), stack, errors);
+ }
+ case JtdSchema.TypeSchema type -> stepType(frame, type, errors);
+ case JtdSchema.EnumSchema enumS -> stepEnum(frame, enumS, errors);
+ case JtdSchema.ElementsSchema elems -> stepElements(frame, elems, stack, errors);
+ case JtdSchema.PropertiesSchema props -> stepProperties(frame, props, stack, errors);
+ case JtdSchema.ValuesSchema vals -> stepValues(frame, vals, stack, errors);
+ case JtdSchema.DiscriminatorSchema disc -> stepDiscriminator(frame, disc, stack, errors);
+ case JtdSchema.NullableSchema n -> throw new AssertionError("unreachable: handled above");
+ }
+ }
+
+ private void stepType(Frame frame, JtdSchema.TypeSchema type, List errors) {
+ final var instance = frame.instance();
+ final var ok = switch (type.type()) {
+ case "boolean" -> instance instanceof jdk.sandbox.java.util.json.JsonBoolean;
+ case "string" -> instance instanceof jdk.sandbox.java.util.json.JsonString;
+ case "timestamp" -> isTimestamp(instance);
+ case "float32", "float64" -> instance instanceof jdk.sandbox.java.util.json.JsonNumber;
+ case "int8" -> isIntInRange(instance, -128, 127);
+ case "uint8" -> isIntInRange(instance, 0, 255);
+ case "int16" -> isIntInRange(instance, -32768, 32767);
+ case "uint16" -> isIntInRange(instance, 0, 65535);
+ case "int32" -> isIntInRange(instance, Integer.MIN_VALUE, Integer.MAX_VALUE);
+ case "uint32" -> isIntInRange(instance, 0, 4294967295L);
+ default -> false;
+ };
+ if (!ok) {
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/type"));
+ }
+ }
+
+ private void stepEnum(Frame frame, JtdSchema.EnumSchema enumS, List errors) {
+ if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonString str)
+ || !enumS.values().contains(str.string())) {
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/enum"));
+ }
+ }
+
+ private void stepElements(Frame frame, JtdSchema.ElementsSchema elems,
+ java.util.Deque stack, List errors) {
+ if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonArray arr)) {
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/elements"));
+ return;
+ }
+ final var childSchemaPath = frame.schemaPath() + "/elements";
+ int i = 0;
+ for (final var element : arr.elements()) {
+ stack.push(new Frame(elems.elements(), element,
+ frame.ptr() + "/" + i,
+ frame.crumbs().withArrayIndex(i),
+ childSchemaPath, null));
+ i++;
+ }
+ }
+
+ private void stepProperties(Frame frame, JtdSchema.PropertiesSchema props,
+ java.util.Deque stack, List errors) {
+ if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) {
+ final var guardPath = props.properties().isEmpty() ? "/optionalProperties" : "/properties";
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + guardPath));
+ return;
+ }
+
+ final var members = obj.members();
+ final var discKey = frame.discriminatorKey();
+ final var sp = frame.schemaPath();
+
+ for (final var entry : props.properties().entrySet()) {
+ final var key = entry.getKey();
+ if (!members.containsKey(key)) {
+ errors.add(new JtdValidationError(frame.ptr(), sp + "/properties/" + key));
+ }
+ }
+
+ if (!props.additionalProperties()) {
+ for (final var key : members.keySet()) {
+ if (!props.properties().containsKey(key)
+ && !props.optionalProperties().containsKey(key)
+ && !key.equals(discKey)) {
+ errors.add(new JtdValidationError(frame.ptr() + "/" + key, sp));
+ }
+ }
+ }
+
+ for (final var entry : props.properties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discKey)) continue;
+ final var value = members.get(key);
+ if (value != null) {
+ stack.push(new Frame(entry.getValue(), value,
+ frame.ptr() + "/" + key,
+ frame.crumbs().withObjectField(key),
+ sp + "/properties/" + key, null));
+ }
+ }
+
+ for (final var entry : props.optionalProperties().entrySet()) {
+ final var key = entry.getKey();
+ if (key.equals(discKey)) continue;
+ final var value = members.get(key);
+ if (value != null) {
+ stack.push(new Frame(entry.getValue(), value,
+ frame.ptr() + "/" + key,
+ frame.crumbs().withObjectField(key),
+ sp + "/optionalProperties/" + key, null));
+ }
+ }
+ }
+
+ private void stepValues(Frame frame, JtdSchema.ValuesSchema vals,
+ java.util.Deque stack, List errors) {
+ if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) {
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/values"));
+ return;
+ }
+ final var childSchemaPath = frame.schemaPath() + "/values";
+ for (final var entry : obj.members().entrySet()) {
+ stack.push(new Frame(vals.values(), entry.getValue(),
+ frame.ptr() + "/" + entry.getKey(),
+ frame.crumbs().withObjectField(entry.getKey()),
+ childSchemaPath, null));
+ }
+ }
+
+ private void stepDiscriminator(Frame frame, JtdSchema.DiscriminatorSchema disc,
+ java.util.Deque stack, List errors) {
+ if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) {
+ errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/discriminator"));
+ return;
+ }
+
+ final var members = obj.members();
+ final var sp = frame.schemaPath();
+
+ if (!members.containsKey(disc.discriminator())) {
+ errors.add(new JtdValidationError(frame.ptr(), sp + "/discriminator"));
+ return;
+ }
+
+ final var tagValue = members.get(disc.discriminator());
+ if (!(tagValue instanceof jdk.sandbox.java.util.json.JsonString tagStr)) {
+ errors.add(new JtdValidationError(
+ frame.ptr() + "/" + disc.discriminator(),
+ sp + "/discriminator"));
+ return;
+ }
+
+ final var variant = disc.mapping().get(tagStr.string());
+ if (variant == null) {
+ errors.add(new JtdValidationError(
+ frame.ptr() + "/" + disc.discriminator(),
+ sp + "/mapping"));
+ return;
+ }
+
+ stack.push(new Frame(variant, frame.instance(), frame.ptr(),
+ frame.crumbs(),
+ sp + "/mapping/" + tagStr.string(),
+ disc.discriminator()));
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private static boolean isTimestamp(JsonValue instance) {
+ if (!(instance instanceof jdk.sandbox.java.util.json.JsonString str)) return false;
+ final var value = str.string();
+ if (!JtdSchema.TypeSchema.RFC3339.matcher(value).matches()) return false;
+ try {
+ final var normalized = value.replace(":60", ":59");
+ java.time.OffsetDateTime.parse(normalized, java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static boolean isIntInRange(JsonValue instance, long min, long max) {
+ if (!(instance instanceof jdk.sandbox.java.util.json.JsonNumber num)) return false;
+ final var d = num.toDouble();
+ if (d != Math.floor(d)) return false;
+ if (d > Long.MAX_VALUE || d < Long.MIN_VALUE) return false;
+ final var l = num.toLong();
+ return l >= min && l <= max;
+ }
+}
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java b/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java
index 148c44b..7c8c4d6 100644
--- a/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java
@@ -57,6 +57,16 @@ public void compile(JsonValue schema) {
compileSchema(schema);
}
+ /// Compiles a JTD schema and returns the immutable AST.
+ ///
+ /// @param schema The JTD schema as a JsonValue
+ /// @return the compiled [JtdSchema] AST
+ /// @throws IllegalArgumentException if the schema is invalid
+ public JtdSchema compileToSchema(JsonValue schema) {
+ definitions.clear();
+ return compileSchema(schema);
+ }
+
/// Validates a JSON instance against a JTD schema
/// @param schema The JTD schema as a JsonValue
/// @param instance The JSON instance to validate
@@ -253,7 +263,7 @@ void pushChildFrames(Frame frame, java.util.Deque stack) {
JtdSchema variantSchema = discSchema.mapping().get(discriminatorValueStr);
if (variantSchema != null) {
- Frame variantFrame = new Frame(variantSchema, instance, frame.ptr(), frame.crumbs(), discSchema.discriminator());
+ Frame variantFrame = new Frame(variantSchema, instance, frame.ptr(), frame.crumbs(), frame.schemaPath(), discSchema.discriminator());
stack.push(variantFrame);
LOG.finer(() -> "Pushed discriminator variant frame for " + discriminatorValueStr + " with discriminator key: " + discSchema.discriminator());
}
@@ -264,7 +274,7 @@ void pushChildFrames(Frame frame, java.util.Deque stack) {
try {
JtdSchema resolved = refSchema.target();
Frame resolvedFrame = new Frame(resolved, instance, frame.ptr(),
- frame.crumbs(), frame.discriminatorKey());
+ frame.crumbs(), frame.schemaPath(), frame.discriminatorKey());
pushChildFrames(resolvedFrame, stack);
LOG.finer(() -> "Pushed ref schema resolved to " +
resolved.getClass().getSimpleName() + " for ref: " + refSchema.ref());
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java
index 6826e46..fcfc8ff 100644
--- a/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java
@@ -9,7 +9,7 @@
/// JTD Schema interface - validates JSON instances against JTD schemas
/// Following RFC 8927 specification with eight mutually-exclusive schema forms
-sealed interface JtdSchema {
+public sealed interface JtdSchema {
/// Core frame-based validation that all schema variants must implement.
/// @param frame Current validation frame
@@ -68,7 +68,7 @@ public boolean validateWithFrame(Frame frame, java.util.List errors, boo
/// Ref schema - references a definition in the schema's definitions
record RefSchema(String ref, java.util.Map definitions) implements JtdSchema {
- JtdSchema target() {
+ public JtdSchema target() {
JtdSchema schema = definitions.get(ref);
if (schema == null) {
throw new IllegalStateException("Ref not resolved: " + ref);
@@ -86,7 +86,7 @@ public Jtd.Result validate(JsonValue instance) {
public boolean validateWithFrame(Frame frame, java.util.List errors, boolean verboseErrors) {
JtdSchema resolved = target();
Frame resolvedFrame = new Frame(resolved, frame.instance(), frame.ptr(),
- frame.crumbs(), frame.discriminatorKey());
+ frame.crumbs(), frame.schemaPath(), frame.discriminatorKey());
return resolved.validateWithFrame(resolvedFrame, errors, verboseErrors);
}
@@ -99,7 +99,7 @@ public String toString() {
/// Type schema - validates specific primitive types
record TypeSchema(String type) implements JtdSchema {
/// RFC 3339 timestamp pattern with leap second support
- private static final java.util.regex.Pattern RFC3339 = java.util.regex.Pattern.compile(
+ static final java.util.regex.Pattern RFC3339 = java.util.regex.Pattern.compile(
"^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:(\\d{2}|60)(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2}))$"
);
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java
new file mode 100644
index 0000000..6691259
--- /dev/null
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java
@@ -0,0 +1,15 @@
+package json.java21.jtd;
+
+/// RFC 8927 validation error indicator: a pair of JSON Pointers.
+///
+/// - `instancePath` points to the value in the JSON document that failed.
+/// - `schemaPath` points to the keyword in the JTD schema that caused the failure.
+///
+/// Both paths follow RFC 6901 (JSON Pointer) notation.
+public record JtdValidationError(String instancePath, String schemaPath) {
+
+ @Override
+ public String toString() {
+ return "{instancePath=\"" + instancePath + "\", schemaPath=\"" + schemaPath + "\"}";
+ }
+}
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java
new file mode 100644
index 0000000..5fc9af4
--- /dev/null
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java
@@ -0,0 +1,21 @@
+package json.java21.jtd;
+
+import java.util.Collections;
+import java.util.List;
+
+/// Result of validating a JSON instance against a JTD schema.
+///
+/// When `isValid()` is true the error list is empty.
+/// When `isValid()` is false at least one [JtdValidationError] is present.
+public record JtdValidationResult(boolean isValid, List errors) {
+
+ private static final JtdValidationResult SUCCESS = new JtdValidationResult(true, List.of());
+
+ public static JtdValidationResult success() {
+ return SUCCESS;
+ }
+
+ public static JtdValidationResult failure(List errors) {
+ return new JtdValidationResult(false, Collections.unmodifiableList(errors));
+ }
+}
diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java
new file mode 100644
index 0000000..915901f
--- /dev/null
+++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java
@@ -0,0 +1,77 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.JsonValue;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Objects;
+import java.util.logging.Logger;
+
+/// Functional interface for validating a JSON instance against a compiled JTD schema.
+///
+/// Obtain an instance via the static factory methods:
+/// - [#compile(JsonValue)] -- interpreter path, always available.
+/// - [#compileGenerated(JsonValue)] -- bytecode-generated path, requires
+/// `json-java21-jtd-codegen` on the classpath (JDK 24+ build).
+@FunctionalInterface
+public interface JtdValidator {
+
+ Logger LOG = Logger.getLogger(JtdValidator.class.getName());
+
+ /// Validates an instance against the compiled schema.
+ ///
+ /// @param instance the JSON value to validate
+ /// @return the validation result with RFC 8927 error pairs
+ JtdValidationResult validate(JsonValue instance);
+
+ // ------------------------------------------------------------------
+ // Factory: interpreter path (always available)
+ // ------------------------------------------------------------------
+
+ /// Compiles a JTD schema into a reusable validator using the stack-machine
+ /// interpreter. Works on any JDK 21+ runtime with zero extra dependencies.
+ ///
+ /// @param schema the JTD schema as a parsed [JsonValue]
+ /// @return a reusable [JtdValidator]
+ /// @throws IllegalArgumentException if the schema is invalid per RFC 8927
+ static JtdValidator compile(JsonValue schema) {
+ Objects.requireNonNull(schema, "schema must not be null");
+ final var jtd = new Jtd();
+ final var compiled = jtd.compileToSchema(schema);
+ return new InterpreterValidator(compiled, jtd, schema.toString());
+ }
+
+ // ------------------------------------------------------------------
+ // Factory: codegen path (optional, requires extra module)
+ // ------------------------------------------------------------------
+
+ String CODEGEN_CLASS = "json.java21.jtd.codegen.JtdCodegen";
+ String CODEGEN_METHOD = "compile";
+
+ /// Compiles a JTD schema into a bytecode-generated validator.
+ /// Requires the `json-java21-jtd-codegen` module on the classpath.
+ ///
+ /// @param schema the JTD schema as a parsed [JsonValue]
+ /// @return a reusable [JtdValidator] backed by generated bytecode
+ /// @throws UnsupportedOperationException if the codegen module is not on the classpath
+ /// @throws IllegalArgumentException if the schema is invalid per RFC 8927
+ static JtdValidator compileGenerated(JsonValue schema) {
+ Objects.requireNonNull(schema, "schema must not be null");
+ try {
+ final var clazz = Class.forName(CODEGEN_CLASS);
+ final var method = clazz.getMethod(CODEGEN_METHOD, JsonValue.class);
+ return (JtdValidator) method.invoke(null, schema);
+ } catch (ClassNotFoundException e) {
+ throw new UnsupportedOperationException(
+ "Codegen module not on classpath. Add json-java21-jtd-codegen dependency, "
+ + "or use JtdValidator.compile() for the interpreter path.", e);
+ } catch (InvocationTargetException e) {
+ final var cause = e.getCause();
+ if (cause instanceof IllegalArgumentException iae) throw iae;
+ if (cause instanceof RuntimeException re) throw re;
+ throw new RuntimeException("Codegen compilation failed", cause);
+ } catch (ReflectiveOperationException e) {
+ throw new UnsupportedOperationException("Codegen module found but incompatible", e);
+ }
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java b/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java
index 58ae42f..a96abf6 100644
--- a/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java
@@ -84,41 +84,8 @@ private Stream runInvalidSchemaCompilationTests() throws Exception
}
private void extractTestData() throws IOException {
- // Check if test data is already extracted
- if (Files.exists(INVALID_SCHEMAS_FILE)) {
- LOG.fine(() -> "JTD invalid schemas test suite already extracted at: " + INVALID_SCHEMAS_FILE);
- return;
- }
-
- // Extract the embedded test suite
- Path zipFile = Paths.get("src/test/resources/jtd-test-suite.zip");
- Path targetDir = Paths.get("target/test-data");
-
- if (!Files.exists(zipFile)) {
- throw new RuntimeException("JTD test suite ZIP not found: " + zipFile.toAbsolutePath());
- }
-
- LOG.info(() -> "Extracting JTD test suite from: " + zipFile);
-
- // Create target directory
- Files.createDirectories(targetDir);
-
- // Extract ZIP file
- try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipFile))) {
- java.util.zip.ZipEntry entry;
- while ((entry = zis.getNextEntry()) != null) {
- if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) {
- Path outputPath = targetDir.resolve(entry.getName());
- Files.createDirectories(outputPath.getParent());
- Files.copy(zis, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
- }
- zis.closeEntry();
- }
- }
-
- if (!Files.exists(INVALID_SCHEMAS_FILE)) {
- throw new RuntimeException("Extraction completed but test file not found: " + INVALID_SCHEMAS_FILE);
- }
+ // Use shared extractor to ensure test data is available
+ JtdTestDataExtractor.ensureValidationTestData();
}
@SuppressWarnings("SameParameterValue")
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java
new file mode 100644
index 0000000..1008065
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java
@@ -0,0 +1,384 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Discriminator schema edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Discriminator field handling when present vs missing
+/// 2. Discriminator field exemption from additionalProperties
+/// 3. Discriminator in optionalProperties vs properties
+/// 4. Empty discriminator object validation
+/// 5. Discriminator with empty mapping value
+public class DiscriminatorEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Missing discriminator field should produce correct error
+ /// RFC 8927 §3.3.8: Step 2 - check if discriminator property exists
+ /// This should be distinguishable from "discriminator present but not string"
+ @Test
+ public void probeMissingVsNonStringDiscriminatorErrors() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "type1": {"properties": {"value": {"type": "string"}}}
+ }
+ }
+ """);
+
+ // Case 1: Missing discriminator
+ JsonValue missing = Json.parse("{\"value\": \"test\"}");
+ Jtd.Result missingResult = new Jtd().validate(schema, missing);
+
+ assertFalse(missingResult.isValid(), "Should fail when discriminator missing");
+ String missingError = missingResult.errors().get(0);
+ LOG.info(() -> "Missing discriminator error: " + missingError);
+
+ // Case 2: Discriminator present but not string
+ JsonValue nonString = Json.parse("{\"kind\": 123}");
+ Jtd.Result nonStringResult = new Jtd().validate(schema, nonString);
+
+ assertFalse(nonStringResult.isValid(), "Should fail when discriminator not string");
+ String nonStringError = nonStringResult.errors().get(0);
+ LOG.info(() -> "Non-string discriminator error: " + nonStringError);
+
+ // Probe: Are these errors distinguishable?
+ // RFC says they should have different schemaPaths
+ // Missing: schema at discriminator form level
+ // Non-string: schemaPath "/discriminator"
+
+ // Implementation currently conflates these (both say "must be a string")
+ // This test documents that behavior
+ }
+
+ /// Test: Discriminator field should be exempt from additionalProperties only once
+ /// RFC 8927 §2.2.8: "The discriminator tag is exempt from additionalProperties enforcement"
+ @Test
+ public void probeDiscriminatorExemptionFromAdditionalProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "data": {
+ "properties": {
+ "value": {"type": "string"}
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ """);
+
+ // Valid: discriminator + defined property
+ JsonValue valid = Json.parse("{\"type\": \"data\", \"value\": \"test\"}");
+ Jtd.Result validResult = new Jtd().validate(schema, valid);
+ assertTrue(validResult.isValid(), "Should accept discriminator + defined properties");
+
+ // Invalid: discriminator + extra property
+ JsonValue invalid = Json.parse("{\"type\": \"data\", \"value\": \"test\", \"extra\": \"bad\"}");
+ Jtd.Result invalidResult = new Jtd().validate(schema, invalid);
+
+ assertFalse(invalidResult.isValid(), "Should reject extra properties beyond discriminator");
+ LOG.info(() -> "Additional property error: " + invalidResult.errors().get(0));
+
+ // Probe: Error should mention 'extra', not 'type'
+ String error = invalidResult.errors().get(0);
+ assertThat(error).as("Error should mention the extra property, not discriminator")
+ .contains("extra");
+ assertThat(error).as("Error should NOT mention the discriminator field")
+ .doesNotContain("type");
+ }
+
+ /// Test: Discriminator field defined in mapping's properties
+ /// RFC 8927 §2.2.8: Discriminator key cannot be redefined in properties
+ @Test
+ public void probeDiscriminatorKeyRedefinitionCompilation() {
+ // This should FAIL compilation per RFC 8927
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "person": {
+ "properties": {
+ "kind": {"type": "string"},
+ "name": {"type": "string"}
+ }
+ }
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Probe: Does compilation reject this?
+ // Per RFC: "Mapped schemas cannot define the discriminator key in properties"
+ try {
+ validator.compile(schema);
+ LOG.warning(() -> "COMPILATION BUG: Should have rejected discriminator key redefinition");
+ // If we get here, compilation allowed an invalid schema
+ } catch (IllegalArgumentException e) {
+ LOG.info(() -> "Correctly rejected discriminator key redefinition: " + e.getMessage());
+ }
+ }
+
+ /// Test: Discriminator field defined in mapping's optionalProperties
+ /// RFC 8927 §2.2.8: Discriminator key cannot be redefined in optionalProperties either
+ @Test
+ public void probeDiscriminatorKeyInOptionalPropertiesCompilation() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "data": {
+ "optionalProperties": {
+ "type": {"type": "string"}
+ }
+ }
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Probe: Does compilation reject this?
+ try {
+ validator.compile(schema);
+ LOG.warning(() -> "COMPILATION BUG: Should have rejected discriminator key in optionalProperties");
+ } catch (IllegalArgumentException e) {
+ LOG.info(() -> "Correctly rejected discriminator key in optionalProperties: " + e.getMessage());
+ }
+ }
+
+ /// Test: Discriminator with empty properties mapping
+ /// Valid per RFC 8927: mapping value can be {"properties": {}}
+ @Test
+ public void probeDiscriminatorWithEmptyPropertiesMapping() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "empty": {"properties": {}}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("{\"kind\": \"empty\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Empty properties mapping result: " + result.isValid());
+
+ // Per RFC 8927: Empty properties should accept any properties except
+ // additional ones (if additionalProperties is false, default is false)
+ // But discriminator field IS the only allowed field
+
+ // Probe: Is this valid?
+ // Should be valid because discriminator is allowed and there are no required properties
+ if (!result.isValid()) {
+ LOG.warning(() -> "VALIDATION BUG: Empty properties with discriminator should be valid, got: " + result.errors());
+ }
+ }
+
+ /// Test: Discriminator with additionalProperties: true
+ @Test
+ public void probeDiscriminatorWithAdditionalPropertiesTrue() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "open": {
+ "properties": {},
+ "additionalProperties": true
+ }
+ }
+ }
+ """);
+
+ // Should accept any properties including extras
+ JsonValue instance = Json.parse("{\"type\": \"open\", \"anything\": \"goes\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "AdditionalProperties: true result: " + result.isValid());
+
+ // Probe: This should definitely be valid
+ assertTrue(result.isValid(), "With additionalProperties: true, extra fields should be allowed");
+ }
+
+ /// Test: Nested discriminator in properties
+ /// Discriminator inside a properties schema that is itself nested
+ @Test
+ public void probeNestedDiscriminator() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "data": {
+ "discriminator": "kind",
+ "mapping": {
+ "a": {"properties": {"value": {"type": "string"}}},
+ "b": {"properties": {"count": {"type": "int32"}}}
+ }
+ }
+ }
+ }
+ """);
+
+ JsonValue valid = Json.parse("{\"data\": {\"kind\": \"a\", \"value\": \"test\"}}");
+ Jtd.Result result = new Jtd().validate(schema, valid);
+
+ LOG.info(() -> "Nested discriminator result: " + result.isValid());
+ assertTrue(result.isValid(), "Nested discriminator should work");
+ }
+
+ /// Test: Discriminator validation stops on first discriminator error
+ /// RFC says if discriminator fails, we shouldn't validate variant schema
+ @Test
+ public void probeDiscriminatorErrorShortCircuitsVariantValidation() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "data": {
+ "properties": {
+ "requiredField": {"type": "string"}
+ }
+ }
+ }
+ }
+ """);
+
+ // Invalid discriminator + missing required field
+ JsonValue instance = Json.parse("{\"type\": \"unknown\", \"other\": \"value\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Errors for unknown discriminator: " + result.errors().size());
+
+ // Should only report discriminator error, not missing required field error
+ // because we never reached variant validation
+ assertFalse(result.isValid());
+
+ // Probe: Are there multiple errors or just the discriminator error?
+ if (result.errors().size() > 1) {
+ LOG.info(() -> "Implementation validates variant even with invalid discriminator");
+ } else {
+ LOG.info(() -> "Implementation correctly short-circuits on discriminator error");
+ }
+ }
+
+ /// Test: Discriminator with null value
+ @Test
+ public void probeDiscriminatorWithNullValue() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "data": {"properties": {}}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("{\"kind\": null}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Discriminator with null result: " + result.isValid());
+
+ // Null is not a string, so should fail
+ assertFalse(result.isValid(), "Discriminator with null value should be invalid");
+ }
+
+ /// Test: Discriminator with empty string value
+ @Test
+ public void probeDiscriminatorWithEmptyString() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "": {"properties": {}}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("{\"kind\": \"\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Discriminator with empty string result: " + result.isValid());
+
+ // Empty string is a valid key in the mapping, so this should be valid
+ assertTrue(result.isValid(), "Empty string discriminator should work if in mapping");
+ }
+
+ /// Test: Multiple discriminator values with same required property name
+ @Test
+ public void probeDiscriminatorVariantsWithCommonProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "person": {
+ "properties": {
+ "name": {"type": "string"}
+ }
+ },
+ "company": {
+ "properties": {
+ "name": {"type": "string"}
+ }
+ }
+ }
+ }
+ """);
+
+ // Both variants have a "name" property
+ JsonValue person = Json.parse("{\"type\": \"person\", \"name\": \"Alice\"}");
+ JsonValue company = Json.parse("{\"type\": \"company\", \"name\": \"Acme\"}");
+
+ Jtd validator = new Jtd();
+
+ Jtd.Result personResult = validator.validate(schema, person);
+ Jtd.Result companyResult = validator.validate(schema, company);
+
+ LOG.info(() -> "Person result: " + personResult.isValid());
+ LOG.info(() -> "Company result: " + companyResult.isValid());
+
+ assertTrue(personResult.isValid(), "Person variant should validate");
+ assertTrue(companyResult.isValid(), "Company variant should validate");
+ }
+
+ /// Test: Discriminator with properties that conflict across variants
+ /// Same property name but different types in different variants
+ @Test
+ public void probeDiscriminatorWithConflictingPropertyTypes() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "version1": {
+ "properties": {
+ "value": {"type": "string"}
+ }
+ },
+ "version2": {
+ "properties": {
+ "value": {"type": "int32"}
+ }
+ }
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ JsonValue v1Valid = Json.parse("{\"type\": \"version1\", \"value\": \"text\"}");
+ JsonValue v1Invalid = Json.parse("{\"type\": \"version1\", \"value\": 123}");
+ JsonValue v2Valid = Json.parse("{\"type\": \"version2\", \"value\": 456}");
+ JsonValue v2Invalid = Json.parse("{\"type\": \"version2\", \"value\": \"text\"}");
+
+ assertTrue(validator.validate(schema, v1Valid).isValid(), "v1 string should be valid");
+ assertFalse(validator.validate(schema, v1Invalid).isValid(), "v1 int should be invalid");
+ assertTrue(validator.validate(schema, v2Valid).isValid(), "v2 int should be valid");
+ assertFalse(validator.validate(schema, v2Invalid).isValid(), "v2 string should be invalid");
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java
new file mode 100644
index 0000000..9008ce3
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java
@@ -0,0 +1,530 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Elements form edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Empty arrays
+/// 2. Nested elements
+/// 3. Elements with complex schemas
+/// 4. Large arrays
+/// 5. Array element error reporting
+public class ElementsEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Empty array validation
+ @Test
+ public void probeEmptyArray() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ JsonValue emptyArray = Json.parse("[]");
+ Jtd.Result result = new Jtd().validate(schema, emptyArray);
+
+ LOG.info(() -> "Empty array validation: " + result.isValid());
+ assertTrue(result.isValid(), "Empty array should be valid");
+ assertThat(result.errors()).isEmpty();
+ }
+
+ /// Test: Single element array
+ @Test
+ public void probeSingleElementArray() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"int32\"}}");
+
+ assertTrue(new Jtd().validate(schema, Json.parse("[42]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[\"not-int\"]")).isValid());
+
+ LOG.info(() -> "Single element array: passed");
+ }
+
+ /// Test: Nested elements (2D array)
+ @Test
+ public void probeNestedElements2D() {
+ JsonValue schema = Json.parse("{\"elements\": {\"elements\": {\"type\": \"string\"}}}");
+
+ // Valid 2D array
+ assertTrue(new Jtd().validate(schema, Json.parse("[[\"a\", \"b\"], [\"c\"]]")).isValid());
+
+ // Invalid inner element
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("[[\"a\"], [123]]"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Nested elements 2D: " + !result.isValid());
+ }
+
+ /// Test: Deeply nested elements (3D array)
+ @Test
+ public void probeNestedElements3D() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "elements": {
+ "elements": {"type": "int32"}
+ }
+ }
+ }
+ """);
+
+ // Valid 3D array
+ JsonValue valid = Json.parse("[[[1, 2], [3]], [[4]]]");
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ // Invalid at deepest level
+ JsonValue invalid = Json.parse("[[[1, \"bad\"], [3]]]");
+ Jtd.Result result = new Jtd().validate(schema, invalid);
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Nested elements 3D: " + !result.isValid());
+ }
+
+ /// Test: Elements with properties schema
+ @Test
+ public void probeElementsWithProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "properties": {
+ "name": {"type": "string"},
+ "age": {"type": "int32"}
+ }
+ }
+ }
+ """);
+
+ // Valid array of objects
+ JsonValue valid = Json.parse("""
+ [
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 25}
+ ]
+ """);
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ // Invalid: missing required property
+ JsonValue invalid = Json.parse("[{\"name\": \"Charlie\"}]");
+ Jtd.Result result = new Jtd().validate(schema, invalid);
+
+ LOG.info(() -> "Elements with properties: " + !result.isValid());
+ }
+
+ /// Test: Elements with discriminator
+ @Test
+ public void probeElementsWithDiscriminator() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "discriminator": "type",
+ "mapping": {
+ "a": {"properties": {"value": {"type": "string"}}},
+ "b": {"properties": {"count": {"type": "int32"}}}
+ }
+ }
+ }
+ """);
+
+ // Valid heterogeneous array
+ JsonValue valid = Json.parse("""
+ [
+ {"type": "a", "value": "test"},
+ {"type": "b", "count": 5}
+ ]
+ """);
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ LOG.info(() -> "Elements with discriminator: passed");
+ }
+
+ /// Test: Elements error collection (all elements validated)
+ @Test
+ public void probeElementsErrorCollection() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ // All elements invalid
+ JsonValue instance = Json.parse("[1, 2, 3, 4, 5]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Elements error count: " + result.errors().size());
+
+ assertFalse(result.isValid());
+
+ // All 5 should be reported
+ assertThat(result.errors().size())
+ .as("Should report errors for all invalid elements")
+ .isGreaterThanOrEqualTo(5);
+ }
+
+ /// Test: Elements with additionalProperties in nested object
+ @Test
+ public void probeElementsWithStrictNestedObjects() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "properties": {
+ "id": {"type": "int32"}
+ },
+ "additionalProperties": false
+ }
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("[{\"id\": 1}]")).isValid());
+
+ // Invalid: extra property in element
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("[{\"id\": 1, \"extra\": \"bad\"}]"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Elements with strict nested: " + !result.isValid());
+ }
+
+ /// Test: Large array performance probe
+ @Test
+ public void probeLargeArray() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"int32\"}}");
+
+ // Build large array
+ StringBuilder arrayBuilder = new StringBuilder("[");
+ int count = 1000;
+ for (int i = 0; i < count; i++) {
+ if (i > 0) arrayBuilder.append(", ");
+ arrayBuilder.append(i);
+ }
+ arrayBuilder.append("]");
+
+ JsonValue instance = Json.parse(arrayBuilder.toString());
+
+ long start = System.currentTimeMillis();
+ Jtd.Result result = new Jtd().validate(schema, instance);
+ long elapsed = System.currentTimeMillis() - start;
+
+ LOG.info(() -> "Large array (" + count + " elements): valid=" + result.isValid() + ", time=" + elapsed + "ms");
+
+ assertTrue(result.isValid());
+ assertThat(elapsed).isLessThan(5000); // Should complete in reasonable time
+ }
+
+ /// Test: Array with null elements
+ @Test
+ public void probeArrayWithNullElements() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ // Null element should be invalid (string type rejects null)
+ JsonValue instance = Json.parse("[\"valid\", null, \"also-valid\"]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Array with null element: " + !result.isValid());
+ assertFalse(result.isValid());
+ }
+
+ /// Test: Array with nullable elements
+ @Test
+ public void probeArrayWithNullableElements() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}");
+
+ // Null element should now be valid
+ JsonValue instance = Json.parse("[\"valid\", null, \"also-valid\"]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Array with nullable elements: " + result.isValid());
+ assertTrue(result.isValid());
+ }
+
+ /// Test: Mixed valid and invalid elements
+ @Test
+ public void probeMixedValidInvalidElements() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ JsonValue instance = Json.parse("[\"a\", \"b\", 123, \"c\", 456]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Mixed elements error count: " + result.errors().size());
+
+ assertFalse(result.isValid());
+
+ // Should have 2 errors (for the 2 invalid elements)
+ List errors = result.errors();
+ long errorCount = errors.stream().filter(e -> e.contains("expected string")).count();
+
+ LOG.info(() -> "Type errors found: " + errorCount);
+ assertThat(errorCount).isGreaterThanOrEqualTo(2);
+ }
+
+ /// Test: Empty schema in elements (accepts anything)
+ @Test
+ public void probeElementsWithEmptySchema() {
+ JsonValue schema = Json.parse("{\"elements\": {}}");
+
+ // Should accept any elements
+ assertTrue(new Jtd().validate(schema, Json.parse("[1, \"two\", true, null, []]")).isValid());
+ assertTrue(new Jtd().validate(schema, Json.parse("[]")).isValid());
+
+ LOG.info(() -> "Elements with empty schema: passed");
+ }
+
+ /// Test: Elements with ref
+ @Test
+ public void probeElementsWithRef() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "item": {"type": "int32"}
+ },
+ "elements": {"ref": "item"}
+ }
+ """);
+
+ assertTrue(new Jtd().validate(schema, Json.parse("[1, 2, 3]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[1, \"two\", 3]")).isValid());
+
+ LOG.info(() -> "Elements with ref: passed");
+ }
+
+ /// Test: Elements error path construction
+ @Test
+ public void probeElementsErrorPaths() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ JsonValue instance = Json.parse("[\"a\", 123]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Elements error: " + error);
+
+ // Error should indicate array index
+ assertThat(error)
+ .as("Error should reference array element")
+ .containsAnyOf("1", "elements");
+ }
+
+ /// Test: Nested elements error path
+ @Test
+ public void probeNestedElementsErrorPath() {
+ JsonValue schema = Json.parse("{\"elements\": {\"elements\": {\"type\": \"string\"}}}");
+
+ JsonValue instance = Json.parse("[[\"a\"], [\"b\", 123]]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Nested elements error: " + error);
+
+ // Should indicate nested position
+ assertThat(error)
+ .as("Error should indicate nested array position")
+ .containsAnyOf("1", "elements");
+ }
+
+ /// Test: Array with object having additional properties
+ @Test
+ public void probeElementsWithObjectAdditionalProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "properties": {
+ "id": {"type": "int32"}
+ },
+ "additionalProperties": false
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("""
+ [
+ {"id": 1},
+ {"id": 2, "extra": "bad"},
+ {"id": 3}
+ ]
+ """);
+
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Elements with object having extra props: " + !result.isValid());
+ assertFalse(result.isValid());
+ }
+
+ /// Test: Multiple arrays in same schema
+ @Test
+ public void probeMultipleArrays() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "names": {"elements": {"type": "string"}},
+ "ages": {"elements": {"type": "int32"}}
+ }
+ }
+ """);
+
+ // Valid
+ JsonValue valid = Json.parse("""
+ {
+ "names": ["Alice", "Bob"],
+ "ages": [30, 25]
+ }
+ """);
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ // Invalid in second array
+ JsonValue invalid = Json.parse("""
+ {
+ "names": ["Alice", "Bob"],
+ "ages": [30, "twenty-five"]
+ }
+ """);
+ Jtd.Result result = new Jtd().validate(schema, invalid);
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Multiple arrays: " + !result.isValid());
+ }
+
+ /// Test: Elements with values (object values schema)
+ @Test
+ public void probeElementsWithValuesSchema() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "values": {"type": "int32"}
+ }
+ }
+ """);
+
+ // Array of objects with int values
+ JsonValue valid = Json.parse("[\"a\": 1, \"b\": 2}, {\"c\": 3}]");
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ LOG.info(() -> "Elements with values schema: passed");
+ }
+
+ /// Test: Elements with enum
+ @Test
+ public void probeElementsWithEnum() {
+ JsonValue schema = Json.parse("{\"elements\": {\"enum\": [\"red\", \"green\", \"blue\"]}}");
+
+ assertTrue(new Jtd().validate(schema, Json.parse("[\"red\", \"blue\"]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[\"red\", \"yellow\"]")).isValid());
+
+ LOG.info(() -> "Elements with enum: passed");
+ }
+
+ /// Test: Sparse array (with undefined/null gaps)
+ @Test
+ public void probeSparseArray() {
+ // JSON doesn't really have sparse arrays, but let's check null handling
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}");
+
+ // Explicit null elements
+ JsonValue instance = Json.parse("[\"a\", null, \"c\"]");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Array with nulls: " + result.isValid());
+ assertTrue(result.isValid());
+ }
+
+ /// Test: Elements with complex nested structure
+ @Test
+ public void probeElementsWithComplexStructure() {
+ JsonValue schema = Json.parse("""
+ {
+ "elements": {
+ "properties": {
+ "users": {
+ "elements": {
+ "properties": {
+ "name": {"type": "string"},
+ "roles": {
+ "elements": {"enum": [\"admin\", \"user\"]}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ // Valid complex structure
+ JsonValue valid = Json.parse("""
+ [
+ {
+ "users": [
+ {"name": "Alice", "roles": ["admin", "user"]},
+ {"name": "Bob", "roles": ["user"]}
+ ]
+ }
+ ]
+ """);
+
+ assertTrue(new Jtd().validate(schema, valid).isValid());
+
+ LOG.info(() -> "Complex nested elements: passed");
+ }
+
+ /// Test: Array type guard (non-array rejection)
+ @Test
+ public void probeElementsTypeGuard() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+
+ // Non-array values should be rejected
+ assertFalse(new Jtd().validate(schema, Json.parse("\"not-array\"")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("123")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("{}")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("null")).isValid());
+
+ LOG.info(() -> "Elements type guard: passed");
+ }
+
+ /// Test: Elements with timestamp type
+ @Test
+ public void probeElementsWithTimestamp() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"timestamp\"}}");
+
+ // Valid timestamps
+ assertTrue(new Jtd().validate(schema, Json.parse("[\"2023-01-01T00:00:00Z\"]")).isValid());
+
+ // Invalid timestamp
+ assertFalse(new Jtd().validate(schema, Json.parse("[\"invalid\"]")).isValid());
+
+ LOG.info(() -> "Elements with timestamp: passed");
+ }
+
+ /// Test: Elements with boolean type
+ @Test
+ public void probeElementsWithBoolean() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"boolean\"}}");
+
+ assertTrue(new Jtd().validate(schema, Json.parse("[true, false, true]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[true, \"false\"]")).isValid());
+
+ LOG.info(() -> "Elements with boolean: passed");
+ }
+
+ /// Test: Elements with float type
+ @Test
+ public void probeElementsWithFloat() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"float64\"}}");
+
+ assertTrue(new Jtd().validate(schema, Json.parse("[1.5, -3.14, 1e10]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[1.5, \"not-float\"]")).isValid());
+
+ LOG.info(() -> "Elements with float: passed");
+ }
+
+ /// Test: Elements with integer types and boundary values
+ @Test
+ public void probeElementsWithIntegerBoundaries() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"uint8\"}}");
+
+ // At boundary
+ assertTrue(new Jtd().validate(schema, Json.parse("[0, 127, 255]")).isValid());
+
+ // Outside boundary
+ assertFalse(new Jtd().validate(schema, Json.parse("[256]")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("[-1]")).isValid());
+
+ LOG.info(() -> "Elements with uint8 boundaries: passed");
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java
new file mode 100644
index 0000000..de61e65
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java
@@ -0,0 +1,360 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// Probes for RFC 8927 error format compliance issues
+/// Tests that verify the EXACT error format per RFC 8927 Section 3.3
+///
+/// Current implementation issues to probe:
+/// 1. Error format should be {instancePath, schemaPath} pairs, not enriched strings
+/// 2. Schema paths must point to schema keywords (e.g., "/type", "/properties/foo")
+/// 3. Instance paths must be RFC 6901 JSON Pointers
+/// 4. Error indicators must be collected for ALL violations (multiple errors)
+public class ErrorFormatComplianceProbe extends JtdTestBase {
+
+ /// Test: Error format should contain RFC 8927 error indicators
+ /// Expected: Each error has instancePath and schemaPath
+ /// Actual: Implementation returns List of enriched messages
+ ///
+ /// This test documents the deviation from RFC 8927 error format
+ @Test
+ public void probeErrorFormatIsRfc8927Compliant() {
+ JsonValue schema = Json.parse("{\"type\": \"string\"}");
+ JsonValue instance = Json.parse("123");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid(), "Should fail validation");
+
+ List errors = result.errors();
+ assertThat(errors).isNotEmpty();
+
+ // Probe: Check if errors contain RFC 8927 format or enriched strings
+ String firstError = errors.get(0);
+
+ // RFC 8927 format would be something like:
+ // { "instancePath": "", "schemaPath": "/type" }
+ // But implementation returns: "[off=N ptr=# via=#] expected string, got JsonNumber"
+
+ LOG.info(() -> "Probing error format: " + firstError);
+
+ // This assertion documents the current behavior
+ assertThat(firstError)
+ .as("Current implementation returns enriched strings, not RFC error objects")
+ .contains("[off=");
+
+ // These assertions will FAIL if/when we implement RFC format
+ // Currently they pass because we get enriched strings
+ assertThat(firstError)
+ .as("Error should contain schemaPath information")
+ .contains("ptr=");
+ }
+
+ /// Test: Type validation errors should have correct schemaPath
+ /// Expected: schemaPath = "/type" for type violations
+ @Test
+ public void probeTypeErrorSchemaPath() {
+ JsonValue schema = Json.parse("{\"type\": \"int32\"}");
+ JsonValue instance = Json.parse("\"not-a-number\"");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Type error: " + error);
+
+ // Probe: Does error contain schemaPath to the type keyword?
+ // Should be "/type" per RFC 8927
+ assertThat(error)
+ .as("Error should reference the type schema location")
+ .contains("expected int32");
+ }
+
+ /// Test: Properties errors should have correct schemaPath
+ /// Expected: schemaPath = "/properties/" for missing required properties
+ @Test
+ public void probePropertiesErrorSchemaPath() {
+ JsonValue schema = Json.parse("{\"properties\": {\"name\": {\"type\": \"string\"}}}");
+ JsonValue instance = Json.parse("{}");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Missing property error: " + error);
+
+ // Probe: Should contain schemaPath to the missing property definition
+ assertThat(error)
+ .as("Error should reference the missing required property")
+ .contains("missing required property: 'name'");
+ }
+
+ /// Test: InstancePath should be RFC 6901 compliant
+ /// Expected: Root is "", children are "/key" or "/0"
+ /// Actual: Implementation uses "#" for root
+ @Test
+ public void probeInstancePathRfc6901Compliance() {
+ JsonValue schema = Json.parse("{\"type\": \"string\"}");
+ JsonValue instance = Json.parse("\"test\"");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, Json.parse("123"));
+
+ assertFalse(result.isValid());
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Instance path in error: " + error);
+
+ // Probe: Instance path format
+ // RFC 6901: empty string for root
+ // Implementation: "#" for root
+ assertThat(error)
+ .as("Implementation uses # for root, not RFC 6901 empty string")
+ .contains("ptr=#");
+ }
+
+ /// Test: Nested path construction
+ /// Expected: Nested errors have compound instancePaths like "/foo/0/bar"
+ @Test
+ public void probeNestedInstancePathConstruction() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "items": {
+ "elements": {
+ "properties": {
+ "value": {"type": "string"}
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("""
+ {
+ "items": [
+ {"value": "valid"},
+ {"value": 123}
+ ]
+ }
+ """);
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Nested path error: " + error);
+
+ // Probe: Should contain path to the nested invalid element
+ assertThat(error)
+ .as("Error should reference nested path")
+ .contains("items");
+ }
+
+ /// Test: Multiple errors should ALL be collected
+ /// RFC 8927 Section 3.3: errors are collected for all violations
+ @Test
+ public void probeMultipleErrorsCollected() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "a": {"type": "string"},
+ "b": {"type": "string"},
+ "c": {"type": "string"}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("""
+ {
+ "a": 1,
+ "b": 2,
+ "c": 3
+ }
+ """);
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ List errors = result.errors();
+ LOG.info(() -> "Multiple errors count: " + errors.size());
+
+ // Probe: Are all three type errors collected?
+ // Implementation should collect errors for all properties
+ assertThat(errors.size())
+ .as("Should collect errors for all invalid properties")
+ .isGreaterThanOrEqualTo(3);
+ }
+
+ /// Test: Error order is unspecified but all should be present
+ @Test
+ public void probeAllViolationsReported() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "name": {"type": "string"}
+ },
+ "optionalProperties": {
+ "age": {"type": "int32"}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("""
+ {
+ "name": 123,
+ "age": "not-a-number"
+ }
+ """);
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ List errors = result.errors();
+
+ // Probe: Are both errors reported?
+ boolean hasNameError = errors.stream().anyMatch(e -> e.contains("name"));
+ boolean hasAgeError = errors.stream().anyMatch(e -> e.contains("age"));
+
+ assertTrue(hasNameError, "Should report error for 'name' property");
+ assertTrue(hasAgeError, "Should report error for 'age' property");
+ }
+
+ /// Test: Enum error schemaPath
+ /// Expected: schemaPath = "/enum" for enum violations
+ @Test
+ public void probeEnumErrorSchemaPath() {
+ JsonValue schema = Json.parse("{\"enum\": [\"a\", \"b\", \"c\"]}");
+ JsonValue instance = Json.parse("\"d\"");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ String error = result.errors().get(0);
+ LOG.info(() -> "Enum error: " + error);
+
+ // Probe: Should reference the enum constraint
+ assertThat(error)
+ .as("Error should reference enum constraint")
+ .contains("not in enum");
+ }
+
+ /// Test: Elements error schemaPath
+ /// Expected: schemaPath = "/elements" for element validation failures
+ @Test
+ public void probeElementsErrorSchemaPath() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}");
+ JsonValue instance = Json.parse("[1, 2, 3]");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid());
+
+ List errors = result.errors();
+ LOG.info(() -> "Elements errors count: " + errors.size());
+
+ // Probe: Should have 3 errors (one per element)
+ // Note: Some implementations might short-circuit; this checks
+ assertThat(errors.size())
+ .as("Should report errors for invalid elements")
+ .isGreaterThanOrEqualTo(1);
+ }
+
+ /// Test: Discriminator error schemaPath
+ /// Expected: Different schemaPaths for different discriminator failures
+ @Test
+ public void probeDiscriminatorErrorSchemaPaths() {
+ // Test 1: Missing discriminator
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "kind",
+ "mapping": {
+ "person": {"properties": {"name": {"type": "string"}}}
+ }
+ }
+ """);
+
+ JsonValue missingKind = Json.parse("{\"name\": \"John\"}");
+ Jtd.Result result1 = new Jtd().validate(schema, missingKind);
+
+ assertFalse(result1.isValid());
+ LOG.info(() -> "Missing discriminator error: " + result1.errors().get(0));
+
+ // Test 2: Invalid discriminator value
+ JsonValue invalidKind = Json.parse("{\"kind\": \"invalid\", \"name\": \"John\"}");
+ Jtd.Result result2 = new Jtd().validate(schema, invalidKind);
+
+ assertFalse(result2.isValid());
+ LOG.info(() -> "Invalid discriminator error: " + result2.errors().get(0));
+
+ // Test 3: Non-string discriminator
+ JsonValue nonStringKind = Json.parse("{\"kind\": 123}");
+ Jtd.Result result3 = new Jtd().validate(schema, nonStringKind);
+
+ assertFalse(result3.isValid());
+ LOG.info(() -> "Non-string discriminator error: " + result3.errors().get(0));
+ }
+
+ /// Test: Empty schema produces no errors
+ /// RFC 8927 §3.3.1: Empty form accepts all instances and produces no errors
+ @Test
+ public void probeEmptySchemaNoErrors() {
+ JsonValue schema = Json.parse("{}");
+ JsonValue instance = Json.parse("{\"anything\": \"goes\"}");
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertTrue(result.isValid());
+ assertThat(result.errors()).isEmpty();
+
+ LOG.info(() -> "Empty schema validation produced no errors (correct)");
+ }
+
+ /// Test: Error messages should be consistent
+ /// Same violation should produce same error structure
+ @Test
+ public void probeErrorMessageConsistency() {
+ JsonValue schema = Json.parse("{\"type\": \"string\"}");
+
+ // Two identical violations
+ Jtd.Result result1 = new Jtd().validate(schema, Json.parse("1"));
+ Jtd.Result result2 = new Jtd().validate(schema, Json.parse("2"));
+
+ assertFalse(result1.isValid());
+ assertFalse(result2.isValid());
+
+ // Probe: Error structure should be consistent
+ String error1 = result1.errors().get(0);
+ String error2 = result2.errors().get(0);
+
+ // Both should contain same error pattern
+ assertThat(error1).contains("expected string");
+ assertThat(error2).contains("expected string");
+
+ LOG.info(() -> "Error consistency check passed");
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java
new file mode 100644
index 0000000..77a25bf
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java
@@ -0,0 +1,87 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonArray;
+import jdk.sandbox.java.util.json.JsonObject;
+import jdk.sandbox.java.util.json.JsonString;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/// Runs the official json-typedef-spec validation test suite against
+/// the interpreter path. This is the authoritative conformance test.
+///
+/// Test data: `jtd-spec-validation.json` from
+///
+class JtdSpecConformanceTest extends JtdTestBase {
+
+ static Stream cases() throws IOException {
+ // Extract test suite from ZIP (same data as IT tests, avoids committing large JSON)
+ try (final var raw = JtdTestDataExtractor.getValidationTestDataStream()) {
+ final var jsonText = new String(raw.readAllBytes(), StandardCharsets.UTF_8);
+ final var root = Json.parse(jsonText);
+ assert root instanceof JsonObject : "expected top-level object";
+ final var obj = (JsonObject) root;
+
+ return obj.members().entrySet().stream()
+ .map(entry -> Arguments.of(
+ entry.getKey(),
+ entry.getValue()));
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("cases")
+ void interpreterMatchesSpecSuite(String name, JsonValue caseValue) {
+ LOG.info("SPEC: " + name);
+
+ final var caseObj = (JsonObject) caseValue;
+ final var schema = caseObj.members().get("schema");
+ final var instance = caseObj.members().get("instance");
+ final var expectedErrors = (JsonArray) caseObj.members().get("errors");
+
+ final var validator = JtdValidator.compile(schema);
+ final var result = validator.validate(instance);
+
+ final var expected = expectedErrors.elements().stream()
+ .map(e -> {
+ final var errObj = (JsonObject) e;
+ final var ip = toJsonPointer((JsonArray) errObj.members().get("instancePath"));
+ final var sp = toJsonPointer((JsonArray) errObj.members().get("schemaPath"));
+ return new JtdValidationError(ip, sp);
+ })
+ .sorted(ERR_CMP)
+ .toList();
+
+ final var actual = result.errors().stream()
+ .sorted(ERR_CMP)
+ .toList();
+
+ assertThat(actual)
+ .as("errors for: " + name)
+ .containsExactlyElementsOf(expected);
+ }
+
+ private static String toJsonPointer(JsonArray tokens) {
+ if (tokens.elements().isEmpty()) return "";
+ final var sb = new StringBuilder();
+ for (final var token : tokens.elements()) {
+ sb.append('/');
+ sb.append(((JsonString) token).string());
+ }
+ return sb.toString();
+ }
+
+ private static final Comparator ERR_CMP =
+ Comparator.comparing(JtdValidationError::instancePath)
+ .thenComparing(JtdValidationError::schemaPath);
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java
index c9be2c5..20fbac4 100644
--- a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java
@@ -93,41 +93,8 @@ private Stream runValidationTests() throws Exception {
}
private void extractTestData() throws IOException {
- // Check if test data is already extracted
- if (Files.exists(VALIDATION_TEST_FILE)) {
- LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_TEST_FILE);
- return;
- }
-
- // Extract the embedded test suite
- Path zipFile = Paths.get("src/test/resources/jtd-test-suite.zip");
- Path targetDir = Paths.get("target/test-data");
-
- if (!Files.exists(zipFile)) {
- throw new RuntimeException("JTD test suite ZIP not found: " + zipFile.toAbsolutePath());
- }
-
- LOG.info(() -> "Extracting JTD test suite from: " + zipFile);
-
- // Create target directory
- Files.createDirectories(targetDir);
-
- // Extract ZIP file
- try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipFile))) {
- java.util.zip.ZipEntry entry;
- while ((entry = zis.getNextEntry()) != null) {
- if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) {
- Path outputPath = targetDir.resolve(entry.getName());
- Files.createDirectories(outputPath.getParent());
- Files.copy(zis, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
- }
- zis.closeEntry();
- }
- }
-
- if (!Files.exists(VALIDATION_TEST_FILE)) {
- throw new RuntimeException("Extraction completed but test file not found: " + VALIDATION_TEST_FILE);
- }
+ // Use shared extractor to ensure test data is available
+ JtdTestDataExtractor.ensureValidationTestData();
}
private JsonNode loadTestFile() throws IOException {
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java
new file mode 100644
index 0000000..e50021d
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java
@@ -0,0 +1,67 @@
+package json.java21.jtd;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.logging.Logger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/// Shared utility for extracting the JTD test suite from the embedded ZIP file.
+/// Used by both unit tests and integration tests to avoid committing large JSON files.
+final class JtdTestDataExtractor {
+
+ private static final Logger LOG = Logger.getLogger("json.java21.jtd");
+ private static final Path ZIP_FILE = Paths.get("src/test/resources/jtd-test-suite.zip");
+ private static final Path TARGET_DIR = Paths.get("target/test-data");
+ private static final Path VALIDATION_FILE = TARGET_DIR.resolve("json-typedef-spec-2025-09-27/tests/validation.json");
+
+ private JtdTestDataExtractor() {
+ // Utility class
+ }
+
+ /// Ensures the test suite is extracted and returns the path to validation.json.
+ /// Extraction happens at most once per build (target/ is cleaned between builds).
+ static synchronized Path ensureValidationTestData() throws IOException {
+ if (Files.exists(VALIDATION_FILE)) {
+ LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_FILE);
+ return VALIDATION_FILE;
+ }
+
+ if (!Files.exists(ZIP_FILE)) {
+ throw new RuntimeException("JTD test suite ZIP not found: " + ZIP_FILE.toAbsolutePath());
+ }
+
+ LOG.info(() -> "Extracting JTD test suite from: " + ZIP_FILE);
+ Files.createDirectories(TARGET_DIR);
+
+ try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(ZIP_FILE))) {
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) {
+ Path outputPath = TARGET_DIR.resolve(entry.getName());
+ Files.createDirectories(outputPath.getParent());
+ Files.copy(zis, outputPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ zis.closeEntry();
+ }
+ }
+
+ if (!Files.exists(VALIDATION_FILE)) {
+ throw new RuntimeException("Extraction completed but validation.json not found: " + VALIDATION_FILE);
+ }
+
+ LOG.info(() -> "JTD test suite extracted successfully");
+ return VALIDATION_FILE;
+ }
+
+ /// Returns an InputStream for the validation test data, extracting if necessary.
+ /// Suitable for use with classpath-style resource loading patterns.
+ static InputStream getValidationTestDataStream() throws IOException {
+ Path dataFile = ensureValidationTestData();
+ return Files.newInputStream(dataFile);
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java
new file mode 100644
index 0000000..719ea39
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java
@@ -0,0 +1,416 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.logging.Logger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/// Tests for the [JtdValidator] functional interface and [InterpreterValidator].
+///
+/// Exercises the RFC 8927 `(instancePath, schemaPath)` error pair format
+/// produced by the interpreter path.
+class JtdValidatorTest extends JtdTestBase {
+
+ private static final Logger LOG = Logger.getLogger(JtdValidatorTest.class.getName());
+
+ // ------------------------------------------------------------------
+ // Factory smoke tests
+ // ------------------------------------------------------------------
+
+ @Test
+ void compileReturnsValidatorForTypeSchema() {
+ LOG.info("EXECUTING: compileReturnsValidatorForTypeSchema");
+ final var validator = JtdValidator.compile(Json.parse("{\"type\": \"string\"}"));
+ assertThat(validator).isNotNull();
+ assertThat(validator.validate(Json.parse("\"hello\"")).isValid()).isTrue();
+ }
+
+ @Test
+ void compileGeneratedThrowsWhenCodegenNotOnClasspath() {
+ LOG.info("EXECUTING: compileGeneratedThrowsWhenCodegenNotOnClasspath");
+ assertThatThrownBy(() -> JtdValidator.compileGenerated(Json.parse("{\"type\": \"string\"}")))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Codegen module not on classpath");
+ }
+
+ // ------------------------------------------------------------------
+ // Empty form
+ // ------------------------------------------------------------------
+
+ @Test
+ void emptySchemaAcceptsAnything() {
+ LOG.info("EXECUTING: emptySchemaAcceptsAnything");
+ final var v = JtdValidator.compile(Json.parse("{}"));
+ assertThat(v.validate(Json.parse("null")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("42")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("[1,2]")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("{\"a\":1}")).isValid()).isTrue();
+ }
+
+ // ------------------------------------------------------------------
+ // Type form -- error paths
+ // ------------------------------------------------------------------
+
+ @Test
+ void typeStringRejectsNumberWithCorrectPaths() {
+ LOG.info("EXECUTING: typeStringRejectsNumberWithCorrectPaths");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).hasSize(1);
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type");
+ }
+
+ @Test
+ void typeBooleanValid() {
+ LOG.info("EXECUTING: typeBooleanValid");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"boolean\"}"));
+ assertThat(v.validate(Json.parse("true")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("false")).isValid()).isTrue();
+ }
+
+ @Test
+ void typeUint8OutOfRangeErrors() {
+ LOG.info("EXECUTING: typeUint8OutOfRangeErrors");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"uint8\"}"));
+ final var result = v.validate(Json.parse("300"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type");
+ }
+
+ // ------------------------------------------------------------------
+ // Enum form
+ // ------------------------------------------------------------------
+
+ @Test
+ void enumRejectsUnknownValueWithEnumPath() {
+ LOG.info("EXECUTING: enumRejectsUnknownValueWithEnumPath");
+ final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}"));
+ final var result = v.validate(Json.parse("\"c\""));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/enum");
+ }
+
+ @Test
+ void enumRejectsNonStringWithEnumPath() {
+ LOG.info("EXECUTING: enumRejectsNonStringWithEnumPath");
+ final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/enum");
+ }
+
+ // ------------------------------------------------------------------
+ // Elements form
+ // ------------------------------------------------------------------
+
+ @Test
+ void elementsRejectsNonArrayAtRootPath() {
+ LOG.info("EXECUTING: elementsRejectsNonArrayAtRootPath");
+ final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/elements");
+ }
+
+ @Test
+ void elementsReportsChildErrorsWithCorrectPaths() {
+ LOG.info("EXECUTING: elementsReportsChildErrorsWithCorrectPaths");
+ final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}"));
+ final var result = v.validate(Json.parse("[\"ok\", 42, \"fine\", true]"));
+ assertThat(result.isValid()).isFalse();
+ LOG.fine(() -> "Errors: " + result.errors());
+
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/1") && e.schemaPath().equals("/elements/type"));
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/3") && e.schemaPath().equals("/elements/type"));
+ }
+
+ // ------------------------------------------------------------------
+ // Properties form
+ // ------------------------------------------------------------------
+
+ @Test
+ void propertiesRejectsNonObjectWithPropertiesPath() {
+ LOG.info("EXECUTING: propertiesRejectsNonObjectWithPropertiesPath");
+ final var v = JtdValidator.compile(Json.parse(
+ "{\"properties\": {\"name\": {\"type\": \"string\"}}}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/properties");
+ }
+
+ @Test
+ void optionalPropertiesOnlyRejectsNonObjectWithOptionalPath() {
+ LOG.info("EXECUTING: optionalPropertiesOnlyRejectsNonObjectWithOptionalPath");
+ final var v = JtdValidator.compile(Json.parse(
+ "{\"optionalProperties\": {\"email\": {\"type\": \"string\"}}}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/optionalProperties");
+ }
+
+ @Test
+ void propertiesMissingRequiredKeyError() {
+ LOG.info("EXECUTING: propertiesMissingRequiredKeyError");
+ final var schema = Json.parse("""
+ {"properties": {"name": {"type": "string"}, "age": {"type": "uint8"}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"name\": \"Alice\"}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("") && e.schemaPath().equals("/properties/age"));
+ }
+
+ @Test
+ void propertiesAdditionalPropertyError() {
+ LOG.info("EXECUTING: propertiesAdditionalPropertyError");
+ final var schema = Json.parse("""
+ {"properties": {"name": {"type": "string"}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"name\": \"Alice\", \"extra\": true}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/extra") && e.schemaPath().equals(""));
+ }
+
+ @Test
+ void propertiesChildValueError() {
+ LOG.info("EXECUTING: propertiesChildValueError");
+ final var schema = Json.parse("""
+ {"properties": {"age": {"type": "uint8"}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"age\": \"not a number\"}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/age") && e.schemaPath().equals("/properties/age/type"));
+ }
+
+ @Test
+ void optionalPropertiesChildValueError() {
+ LOG.info("EXECUTING: optionalPropertiesChildValueError");
+ final var schema = Json.parse("""
+ {"optionalProperties": {"email": {"type": "string"}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"email\": 42}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/email")
+ && e.schemaPath().equals("/optionalProperties/email/type"));
+ }
+
+ // ------------------------------------------------------------------
+ // Values form
+ // ------------------------------------------------------------------
+
+ @Test
+ void valuesRejectsNonObjectAtRootPath() {
+ LOG.info("EXECUTING: valuesRejectsNonObjectAtRootPath");
+ final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/values");
+ }
+
+ @Test
+ void valuesReportsChildErrors() {
+ LOG.info("EXECUTING: valuesReportsChildErrors");
+ final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}"));
+ final var result = v.validate(Json.parse("{\"a\": \"ok\", \"b\": 42}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/b") && e.schemaPath().equals("/values/type"));
+ }
+
+ // ------------------------------------------------------------------
+ // Discriminator form
+ // ------------------------------------------------------------------
+
+ @Test
+ void discriminatorNotObjectError() {
+ LOG.info("EXECUTING: discriminatorNotObjectError");
+ final var schema = Json.parse("""
+ {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator");
+ }
+
+ @Test
+ void discriminatorMissingTagError() {
+ LOG.info("EXECUTING: discriminatorMissingTagError");
+ final var schema = Json.parse("""
+ {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"x\": 1}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator");
+ }
+
+ @Test
+ void discriminatorTagNotStringError() {
+ LOG.info("EXECUTING: discriminatorTagNotStringError");
+ final var schema = Json.parse("""
+ {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"type\": 42}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator");
+ }
+
+ @Test
+ void discriminatorTagNotInMappingError() {
+ LOG.info("EXECUTING: discriminatorTagNotInMappingError");
+ final var schema = Json.parse("""
+ {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"type\": \"unknown\"}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type");
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/mapping");
+ }
+
+ @Test
+ void discriminatorVariantValidationErrors() {
+ LOG.info("EXECUTING: discriminatorVariantValidationErrors");
+ final var schema = Json.parse("""
+ {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}}
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(Json.parse("{\"type\": \"a\", \"x\": 42}"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/x")
+ && e.schemaPath().equals("/mapping/a/properties/x/type"));
+ }
+
+ // ------------------------------------------------------------------
+ // Nullable
+ // ------------------------------------------------------------------
+
+ @Test
+ void nullableAcceptsNull() {
+ LOG.info("EXECUTING: nullableAcceptsNull");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}"));
+ assertThat(v.validate(Json.parse("null")).isValid()).isTrue();
+ assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue();
+ }
+
+ @Test
+ void nullableStillRejectsWrongType() {
+ LOG.info("EXECUTING: nullableStillRejectsWrongType");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}"));
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type");
+ }
+
+ // ------------------------------------------------------------------
+ // Ref form
+ // ------------------------------------------------------------------
+
+ @Test
+ void refValidatesViaDefinition() {
+ LOG.info("EXECUTING: refValidatesViaDefinition");
+ final var schema = Json.parse("""
+ {"definitions": {"addr": {"type": "string"}}, "ref": "addr"}
+ """);
+ final var v = JtdValidator.compile(schema);
+ assertThat(v.validate(Json.parse("\"hello\"")).isValid()).isTrue();
+ final var result = v.validate(Json.parse("42"));
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/definitions/addr/type");
+ }
+
+ // ------------------------------------------------------------------
+ // toString returns original schema JSON
+ // ------------------------------------------------------------------
+
+ @Test
+ void toStringReturnsSchemaJson() {
+ LOG.info("EXECUTING: toStringReturnsSchemaJson");
+ final var schemaJson = "{\"type\": \"string\"}";
+ final var v = JtdValidator.compile(Json.parse(schemaJson));
+ assertThat(v.toString()).isNotEmpty();
+ LOG.fine(() -> "toString: " + v);
+ }
+
+ // ------------------------------------------------------------------
+ // Functional interface usage (stream pipeline)
+ // ------------------------------------------------------------------
+
+ @Test
+ void usableInStreamPipeline() {
+ LOG.info("EXECUTING: usableInStreamPipeline");
+ final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}"));
+ final var docs = java.util.List.of(
+ Json.parse("\"a\""), Json.parse("42"), Json.parse("\"b\""), Json.parse("true"));
+ final var invalid = docs.stream()
+ .filter(doc -> !v.validate(doc).isValid())
+ .toList();
+ assertThat(invalid).hasSize(2);
+ }
+
+ // ------------------------------------------------------------------
+ // Worked example from JTD_STACK_MACHINE_SPEC.md §10
+ // ------------------------------------------------------------------
+
+ @Test
+ void workedExampleFromSpec() {
+ LOG.info("EXECUTING: workedExampleFromSpec");
+ final var schema = Json.parse("""
+ {
+ "properties": {
+ "name": {"type": "string"},
+ "age": {"type": "uint8"},
+ "tags": {"elements": {"type": "string"}}
+ },
+ "optionalProperties": {
+ "email": {"type": "string"}
+ }
+ }
+ """);
+ final var instance = Json.parse("""
+ {
+ "name": "Alice",
+ "age": 300,
+ "tags": ["a", 42],
+ "extra": true
+ }
+ """);
+ final var v = JtdValidator.compile(schema);
+ final var result = v.validate(instance);
+ assertThat(result.isValid()).isFalse();
+
+ LOG.fine(() -> "Errors: " + result.errors());
+
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/extra") && e.schemaPath().equals(""));
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/age") && e.schemaPath().equals("/properties/age/type"));
+ assertThat(result.errors()).anyMatch(e ->
+ e.instancePath().equals("/tags/1") && e.schemaPath().equals("/properties/tags/elements/type"));
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java
new file mode 100644
index 0000000..cecc32c
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java
@@ -0,0 +1,615 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Nullable modifier edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Nullable on all form types
+/// 2. Nullable false (explicit)
+/// 3. Nested nullable
+/// 4. Nullable with required properties
+/// 5. Nullable ref
+public class NullableEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Nullable type accepts null
+ @Test
+ public void probeNullableTypeAcceptsNull() {
+ JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": true}");
+
+ Jtd validator = new Jtd();
+
+ // Null should be valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // String should also be valid
+ assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid());
+
+ // Other types should be invalid
+ assertFalse(validator.validate(schema, Json.parse("123")).isValid());
+
+ LOG.info(() -> "Nullable type: passed");
+ }
+
+ /// Test: Non-nullable type rejects null
+ @Test
+ public void probeNonNullableTypeRejectsNull() {
+ JsonValue schema = Json.parse("{\"type\": \"string\"}");
+
+ Jtd validator = new Jtd();
+
+ // Null should be rejected
+ Jtd.Result result = validator.validate(schema, Json.parse("null"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Non-nullable type rejects null: " + !result.isValid());
+ }
+
+ /// Test: Nullable explicit false
+ @Test
+ public void probeNullableExplicitFalse() {
+ JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": false}");
+
+ Jtd validator = new Jtd();
+
+ // Should behave same as non-nullable
+ assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid());
+ assertFalse(validator.validate(schema, Json.parse("null")).isValid());
+
+ LOG.info(() -> "Nullable explicit false: passed");
+ }
+
+ /// Test: Nullable on empty schema
+ @Test
+ public void probeNullableEmptySchema() {
+ JsonValue schema = Json.parse("{\"nullable\": true}");
+
+ Jtd validator = new Jtd();
+
+ // Empty schema accepts anything, nullable adds null acceptance
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("\"anything\"")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("123")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("[]")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("{}")).isValid());
+
+ LOG.info(() -> "Nullable empty schema: passed");
+ }
+
+ /// Test: Nullable on enum
+ @Test
+ public void probeNullableEnum() {
+ JsonValue schema = Json.parse("{\"enum\": [\"a\", \"b\", \"c\"], \"nullable\": true}");
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Enum values are valid
+ assertTrue(validator.validate(schema, Json.parse("\"a\"")).isValid());
+
+ // Invalid values still rejected
+ assertFalse(validator.validate(schema, Json.parse("\"d\"")).isValid());
+ assertFalse(validator.validate(schema, Json.parse("123")).isValid());
+
+ LOG.info(() -> "Nullable enum: passed");
+ }
+
+ /// Test: Nullable on elements
+ @Test
+ public void probeNullableElements() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}, \"nullable\": true}");
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Array is valid
+ assertTrue(validator.validate(schema, Json.parse("[\"a\", \"b\"]")).isValid());
+
+ // Invalid element still rejected
+ assertFalse(validator.validate(schema, Json.parse("[\"a\", 123]")).isValid());
+
+ LOG.info(() -> "Nullable elements: passed");
+ }
+
+ /// Test: Nullable on properties
+ @Test
+ public void probeNullableProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "name": {"type": "string"}
+ },
+ "nullable": true
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Object is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"name\": \"test\"}")).isValid());
+
+ // Missing required property still invalid
+ assertFalse(validator.validate(schema, Json.parse("{}")).isValid());
+
+ LOG.info(() -> "Nullable properties: passed");
+ }
+
+ /// Test: Nullable on values
+ @Test
+ public void probeNullableValues() {
+ JsonValue schema = Json.parse("{\"values\": {\"type\": \"int32\"}, \"nullable\": true}");
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Object is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid());
+
+ // Invalid value still rejected
+ assertFalse(validator.validate(schema, Json.parse("{\"a\": \"not-int\"}")).isValid());
+
+ LOG.info(() -> "Nullable values: passed");
+ }
+
+ /// Test: Nullable on discriminator
+ @Test
+ public void probeNullableDiscriminator() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "a": {"properties": {"value": {"type": \"string\"}}}
+ },
+ "nullable": true
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Valid discriminator object
+ assertTrue(validator.validate(schema, Json.parse("{\"type\": \"a\", \"value\": \"test\"}")).isValid());
+
+ // Invalid discriminator still rejected
+ assertFalse(validator.validate(schema, Json.parse("{\"type\": \"unknown\"}")).isValid());
+
+ LOG.info(() -> "Nullable discriminator: passed");
+ }
+
+ /// Test: Nullable on ref
+ @Test
+ public void probeNullableRef() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "item": {"type": \"string\"}
+ },
+ "ref": "item",
+ "nullable": true
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // String is valid
+ assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid());
+
+ // Invalid type still rejected
+ assertFalse(validator.validate(schema, Json.parse("123")).isValid());
+
+ LOG.info(() -> "Nullable ref: passed");
+ }
+
+ /// Test: Nested nullable (nullable inside nullable)
+ @Test
+ public void probeNestedNullable() {
+ // This is technically valid JSON but may not make semantic sense
+ // Testing if the implementation handles it gracefully
+
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "field": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "nullable": true
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Root null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Object with null field is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid());
+
+ // Object with string field is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid());
+
+ LOG.info(() -> "Nested nullable: passed");
+ }
+
+ /// Test: Nullable property value
+ @Test
+ public void probeNullablePropertyValue() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "name": {"type": "string", "nullable": true}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Property with null value is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"name\": null}")).isValid());
+
+ // Property with string value is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"name\": \"test\"}")).isValid());
+
+ // Missing property (if required) would be invalid, but it's optional here
+ LOG.info(() -> "Nullable property value: passed");
+ }
+
+ /// Test: Nullable required property
+ @Test
+ public void probeNullableRequiredProperty() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "id": {"type": "int32", "nullable": true}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Required property with null is valid (present, and null is allowed)
+ assertTrue(validator.validate(schema, Json.parse("{\"id\": null}")).isValid());
+
+ // Required property with int is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"id\": 123}")).isValid());
+
+ // Required property missing is invalid
+ assertFalse(validator.validate(schema, Json.parse("{}")).isValid());
+
+ LOG.info(() -> "Nullable required property: passed");
+ }
+
+ /// Test: Nullable optional property
+ @Test
+ public void probeNullableOptionalProperty() {
+ JsonValue schema = Json.parse("""
+ {
+ "optionalProperties": {
+ "note": {"type": "string", "nullable": true}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Optional property absent is valid
+ assertTrue(validator.validate(schema, Json.parse("{}")).isValid());
+
+ // Optional property with null is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"note\": null}")).isValid());
+
+ // Optional property with string is valid
+ assertTrue(validator.validate(schema, Json.parse("{\"note\": \"test\"}")).isValid());
+
+ LOG.info(() -> "Nullable optional property: passed");
+ }
+
+ /// Test: Nullable array element
+ @Test
+ public void probeNullableArrayElement() {
+ JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}");
+
+ Jtd validator = new Jtd();
+
+ // Array with null element
+ assertTrue(validator.validate(schema, Json.parse("[\"a\", null, \"c\"]")).isValid());
+
+ // Array with all nulls
+ assertTrue(validator.validate(schema, Json.parse("[null, null, null]")).isValid());
+
+ // Mixed valid and null
+ assertTrue(validator.validate(schema, Json.parse("[\"a\", null, \"b\", null]")).isValid());
+
+ LOG.info(() -> "Nullable array element: passed");
+ }
+
+ /// Test: Nullable values in object
+ @Test
+ public void probeNullableValuesInObject() {
+ JsonValue schema = Json.parse("{\"values\": {\"type\": \"int32\", \"nullable\": true}}");
+
+ Jtd validator = new Jtd();
+
+ // Object with null values
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": 123}")).isValid());
+
+ // Object with all null values
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": null}")).isValid());
+
+ LOG.info(() -> "Nullable values in object: passed");
+ }
+
+ /// Test: Nullable with integer types
+ @Test
+ public void probeNullableWithIntegerTypes() {
+ String[] intTypes = {"int8", "uint8", "int16", "uint16", "int32", "uint32"};
+
+ for (String type : intTypes) {
+ JsonValue schema = Json.parse("{\"type\": \"" + type + "\", \"nullable\": true}");
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid(),
+ type + " nullable should accept null");
+
+ // Valid integer is valid
+ if (type.startsWith("u")) {
+ assertTrue(validator.validate(schema, Json.parse("100")).isValid());
+ } else {
+ assertTrue(validator.validate(schema, Json.parse("-50")).isValid());
+ }
+ }
+
+ LOG.info(() -> "Nullable with integer types: passed");
+ }
+
+ /// Test: Nullable with float types
+ @Test
+ public void probeNullableWithFloatTypes() {
+ JsonValue schema32 = Json.parse("{\"type\": \"float32\", \"nullable\": true}");
+ JsonValue schema64 = Json.parse("{\"type\": \"float64\", \"nullable\": true}");
+ Jtd validator = new Jtd();
+
+ // Null valid for both
+ assertTrue(validator.validate(schema32, Json.parse("null")).isValid());
+ assertTrue(validator.validate(schema64, Json.parse("null")).isValid());
+
+ // Floats valid for both
+ assertTrue(validator.validate(schema32, Json.parse("3.14")).isValid());
+ assertTrue(validator.validate(schema64, Json.parse("3.14")).isValid());
+
+ LOG.info(() -> "Nullable with float types: passed");
+ }
+
+ /// Test: Nullable with timestamp
+ @Test
+ public void probeNullableWithTimestamp() {
+ JsonValue schema = Json.parse("{\"type\": \"timestamp\", \"nullable\": true}");
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Valid timestamp is valid
+ assertTrue(validator.validate(schema, Json.parse("\"2023-01-01T00:00:00Z\"")).isValid());
+
+ // Invalid timestamp still rejected
+ assertFalse(validator.validate(schema, Json.parse("\"not-a-timestamp\"")).isValid());
+
+ LOG.info(() -> "Nullable with timestamp: passed");
+ }
+
+ /// Test: Nullable with boolean
+ @Test
+ public void probeNullableWithBoolean() {
+ JsonValue schema = Json.parse("{\"type\": \"boolean\", \"nullable\": true}");
+ Jtd validator = new Jtd();
+
+ // Null is valid
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+
+ // Booleans are valid
+ assertTrue(validator.validate(schema, Json.parse("true")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("false")).isValid());
+
+ // Non-boolean still rejected
+ assertFalse(validator.validate(schema, Json.parse("\"true\"")).isValid());
+
+ LOG.info(() -> "Nullable with boolean: passed");
+ }
+
+ /// Test: Nullable error messages
+ @Test
+ public void probeNullableErrorMessages() {
+ JsonValue nullableSchema = Json.parse("{\"type\": \"string\", \"nullable\": true}");
+ JsonValue nonNullableSchema = Json.parse("{\"type\": \"string\"}");
+
+ Jtd validator = new Jtd();
+
+ // Non-nullable with null
+ Jtd.Result nonNullResult = validator.validate(nonNullableSchema, Json.parse("null"));
+ String nonNullError = nonNullResult.errors().get(0);
+
+ // Nullable with invalid type
+ Jtd.Result nullInvalidResult = validator.validate(nullableSchema, Json.parse("123"));
+ String nullInvalidError = nullInvalidResult.errors().get(0);
+
+ LOG.info(() -> "Non-nullable null error: " + nonNullError);
+ LOG.info(() -> "Nullable invalid type error: " + nullInvalidError);
+
+ // Both should have errors
+ assertFalse(nonNullResult.isValid());
+ assertFalse(nullInvalidResult.isValid());
+ }
+
+ /// Test: Nullable must be boolean compilation check
+ @Test
+ public void probeNullableMustBeBooleanCompilation() {
+ // Invalid: nullable is not a boolean
+ JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": \"yes\"}");
+
+ try {
+ new Jtd().compile(schema);
+ LOG.warning(() -> "COMPILATION BUG: Should reject non-boolean nullable");
+ } catch (IllegalArgumentException e) {
+ LOG.info(() -> "Correctly rejected non-boolean nullable: " + e.getMessage());
+ }
+ }
+
+ /// Test: Nullable in definitions
+ @Test
+ public void probeNullableInDefinitions() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "maybeString": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "properties": {
+ "field": {"ref": "maybeString"}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Field can be null
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid());
+
+ LOG.info(() -> "Nullable in definitions: passed");
+ }
+
+ /// Test: Nullable with complex nested schema
+ @Test
+ public void probeNullableWithComplexNestedSchema() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "data": {
+ "elements": {
+ "properties": {
+ "value": {"type": "string", "nullable": true}
+ }
+ },
+ "nullable": true
+ }
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Null data
+ assertTrue(validator.validate(schema, Json.parse("{\"data\": null}")).isValid());
+
+ // Array with null values
+ assertTrue(validator.validate(schema, Json.parse("{\"data\": [{\"value\": null}]}")).isValid());
+
+ // Array with string values
+ assertTrue(validator.validate(schema, Json.parse("{\"data\": [{\"value\": \"test\"}]}")).isValid());
+
+ LOG.info(() -> "Nullable complex nested: passed");
+ }
+
+ /// Test: Nullable discriminator mapping value
+ /// RFC 8927: Discriminator mapping values cannot be nullable
+ @Test
+ public void probeNullableDiscriminatorMappingValue() {
+ JsonValue schema = Json.parse("""
+ {
+ "discriminator": "type",
+ "mapping": {
+ "data": {
+ "properties": {
+ "value": {"type": "string"}
+ },
+ "nullable": true
+ }
+ }
+ }
+ """);
+
+ try {
+ new Jtd().compile(schema);
+ LOG.warning(() -> "COMPILATION BUG: Should reject nullable discriminator mapping");
+ } catch (IllegalArgumentException e) {
+ LOG.info(() -> "Correctly rejected nullable discriminator mapping: " + e.getMessage());
+ }
+ }
+
+ /// Test: Multiple nullable fields
+ @Test
+ public void probeMultipleNullableFields() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "a": {"type": "string", "nullable": true},
+ "b": {"type": "int32", "nullable": true},
+ "c": {"type": "boolean", "nullable": true}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // All null
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": null, \"c\": null}")).isValid());
+
+ // All valid
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": \"test\", \"b\": 123, \"c\": true}")).isValid());
+
+ // Mixed
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": 456, \"c\": null}")).isValid());
+
+ LOG.info(() -> "Multiple nullable fields: passed");
+ }
+
+ /// Test: Nullable with additionalProperties
+ @Test
+ public void probeNullableWithAdditionalProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "id": {"type": "int32", "nullable": true}
+ },
+ "additionalProperties": false
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid with null
+ assertTrue(validator.validate(schema, Json.parse("{\"id\": null}")).isValid());
+
+ // Valid with int
+ assertTrue(validator.validate(schema, Json.parse("{\"id\": 123}")).isValid());
+
+ // Invalid: extra property
+ assertFalse(validator.validate(schema, Json.parse("{\"id\": 123, \"extra\": \"bad\"}")).isValid());
+
+ LOG.info(() -> "Nullable with additionalProperties: passed");
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java
new file mode 100644
index 0000000..5c55535
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java
@@ -0,0 +1,380 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Properties form edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Empty properties with additionalProperties: false
+/// 2. Additional properties detection with complex keys
+/// 3. Required vs optional property precedence
+/// 4. Properties with null values
+/// 5. Nested properties validation order
+public class PropertiesEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Empty properties with additionalProperties: false
+ /// Should reject ALL additional properties
+ @Test
+ public void probeEmptyPropertiesRejectsAllExtras() {
+ JsonValue schema = Json.parse("{\"properties\": {}, \"additionalProperties\": false}");
+
+ JsonValue instance = Json.parse("{\"anyKey\": \"anyValue\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Empty properties rejects extras: " + !result.isValid());
+
+ // Should reject because there are no allowed properties
+ assertFalse(result.isValid(), "Empty properties + additionalProperties:false should reject all fields");
+
+ // And should report the extra property
+ String error = result.errors().get(0);
+ assertThat(error).contains("anyKey");
+ }
+
+ /// Test: Empty properties without additionalProperties (defaults to false)
+ /// RFC 8927: additionalProperties defaults to false
+ @Test
+ public void probeEmptyPropertiesDefaultAdditionalProperties() {
+ JsonValue schema = Json.parse("{\"properties\": {}}");
+
+ JsonValue instance = Json.parse("{\"surprise\": \"field\"}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Empty properties default strict: " + !result.isValid());
+
+ // Should reject - default is strict
+ assertFalse(result.isValid(), "Empty properties should default to rejecting extras");
+ }
+
+ /// Test: Additional properties with special characters in keys
+ /// Keys with dots, slashes, spaces should be handled correctly
+ @Test
+ public void probeAdditionalPropertiesSpecialKeys() {
+ JsonValue schema = Json.parse("{\"properties\": {\"normal\": {}}, \"additionalProperties\": false}");
+
+ // These should all be rejected as additional properties
+ String[] specialKeys = {
+ "{\"dotted.key\": 1}",
+ "{\"key/with/slash\": 1}",
+ "{\"key with space\": 1}",
+ "{\"\\u0000nullchar\": 1}"
+ };
+
+ Jtd validator = new Jtd();
+
+ for (String keyJson : specialKeys) {
+ try {
+ JsonValue instance = Json.parse(keyJson);
+ Jtd.Result result = validator.validate(schema, instance);
+
+ LOG.fine(() -> "Special key validation: " + keyJson + " -> valid=" + result.isValid());
+
+ // Should be invalid
+ assertFalse(result.isValid(), "Should reject additional property: " + keyJson);
+ } catch (Exception e) {
+ LOG.warning(() -> "Failed to parse or validate: " + keyJson + " - " + e.getMessage());
+ }
+ }
+ }
+
+ /// Test: Properties with duplicate key detection
+ /// This probes if the implementation correctly identifies all extra properties
+ @Test
+ public void probeMultipleAdditionalPropertiesAllReported() {
+ JsonValue schema = Json.parse("{\"properties\": {\"allowed\": {}}, \"additionalProperties\": false}");
+
+ JsonValue instance = Json.parse("{\"allowed\": 1, \"extra1\": 2, \"extra2\": 3, \"extra3\": 4}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Multiple extras error count: " + result.errors().size());
+
+ assertFalse(result.isValid());
+
+ // Probe: Are all three extra properties reported?
+ List errors = result.errors();
+
+ Set reportedExtras = new HashSet<>();
+ for (String error : errors) {
+ if (error.contains("extra1")) reportedExtras.add("extra1");
+ if (error.contains("extra2")) reportedExtras.add("extra2");
+ if (error.contains("extra3")) reportedExtras.add("extra3");
+ }
+
+ LOG.info(() -> "Reported extras: " + reportedExtras);
+
+ // Ideally all three should be reported
+ assertThat(reportedExtras)
+ .as("Should report all additional properties")
+ .hasSizeGreaterThanOrEqualTo(1);
+ }
+
+ /// Test: Required property with null value
+ /// Required means "must be present", null is a valid value
+ @Test
+ public void probeRequiredPropertyWithNullValue() {
+ JsonValue schema = Json.parse("{\"properties\": {\"field\": {}}}");
+
+ JsonValue instance = Json.parse("{\"field\": null}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Required with null result: " + result.isValid());
+
+ // Per RFC 8927: property is present (key exists), so satisfies "required"
+ // Empty schema {} accepts null
+ assertTrue(result.isValid(), "Required property with null should be valid");
+ }
+
+ /// Test: Required property missing vs null value
+ @Test
+ public void probeMissingVsNullProperty() {
+ JsonValue schema = Json.parse("{\"properties\": {\"field\": {}}}");
+
+ // Missing property
+ JsonValue missing = Json.parse("{}");
+ Jtd.Result missingResult = new Jtd().validate(schema, missing);
+
+ // Null property
+ JsonValue nullValue = Json.parse("{\"field\": null}");
+ Jtd.Result nullResult = new Jtd().validate(schema, nullValue);
+
+ LOG.info(() -> "Missing: " + !missingResult.isValid() + ", Null: " + nullResult.isValid());
+
+ assertFalse(missingResult.isValid(), "Missing required property should be invalid");
+ assertTrue(nullResult.isValid(), "Required property with null should be valid");
+ }
+
+ /// Test: Optional property with various values
+ @Test
+ public void probeOptionalPropertyValues() {
+ JsonValue schema = Json.parse("{\"optionalProperties\": {\"opt\": {\"type\": \"string\"}}}");
+
+ Jtd validator = new Jtd();
+
+ // Missing - valid
+ assertTrue(validator.validate(schema, Json.parse("{}")).isValid());
+
+ // Null - should validate against the type schema (empty schema accepts null, but type:string rejects null)
+ Jtd.Result nullResult = validator.validate(schema, Json.parse("{\"opt\": null}"));
+ LOG.info(() -> "Optional null result: " + nullResult.isValid());
+ // This depends on implementation - null with type:string should fail
+
+ // Valid string
+ assertTrue(validator.validate(schema, Json.parse("{\"opt\": \"value\"}")).isValid());
+
+ // Invalid type
+ assertFalse(validator.validate(schema, Json.parse("{\"opt\": 123}")).isValid());
+ }
+
+ /// Test: Properties and optionalProperties with same key
+ /// RFC 8927: This should be a compile-time error
+ @Test
+ public void probeOverlappingRequiredAndOptionalCompilation() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "field": {"type": "string"}
+ },
+ "optionalProperties": {
+ "field": {"type": "int32"}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ try {
+ validator.compile(schema);
+ LOG.warning(() -> "COMPILATION BUG: Should reject overlapping property keys");
+ } catch (IllegalArgumentException e) {
+ LOG.info(() -> "Correctly rejected overlapping keys: " + e.getMessage());
+ }
+ }
+
+ /// Test: Nested properties with different additionalProperties settings
+ @Test
+ public void probeNestedPropertiesDifferentStrictness() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "outer": {
+ "properties": {
+ "inner": {}
+ },
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": false
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid: outer allows extras
+ JsonValue valid = Json.parse("{\"outer\": {\"inner\": 1, \"extra\": 2}}");
+ assertTrue(validator.validate(schema, valid).isValid());
+
+ // Invalid: root level doesn't allow extras
+ JsonValue invalid = Json.parse("{\"outer\": {\"inner\": 1}, \"rootExtra\": 3}");
+ Jtd.Result result = validator.validate(schema, invalid);
+
+ LOG.info(() -> "Root extra rejection: " + !result.isValid());
+ assertFalse(result.isValid(), "Should reject extra property at root level");
+ }
+
+ /// Test: Properties validation order
+ /// Are errors reported in consistent order?
+ @Test
+ public void probePropertiesValidationOrder() {
+ JsonValue schema = Json.parse("""
+ {
+ "properties": {
+ "z": {},
+ "a": {},
+ "m": {}
+ }
+ }
+ """);
+
+ // All missing
+ JsonValue instance = Json.parse("{}");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Missing properties error count: " + result.errors().size());
+
+ assertThat(result.errors().size()).isEqualTo(3);
+
+ // Probe: Order of errors (alphabetical vs declaration order?)
+ String[] errors = result.errors().toArray(new String[0]);
+ LOG.info(() -> "Error order: " + String.join(", ", errors));
+
+ // Note: Order is unspecified by RFC, but should be deterministic
+ }
+
+ /// Test: Empty string property names
+ @Test
+ public void probeEmptyStringPropertyNames() {
+ JsonValue schema = Json.parse("{\"properties\": {\"\": {}}}");
+
+ JsonValue valid = Json.parse("{\"\": \"value\"}");
+ Jtd.Result result = new Jtd().validate(schema, valid);
+
+ LOG.info(() -> "Empty string property result: " + result.isValid());
+
+ // Empty string is a valid property name in JSON
+ assertTrue(result.isValid(), "Empty string property name should be valid");
+ }
+
+ /// Test: Very deep nesting of properties
+ @Test
+ public void probeDeepNestingProperties() {
+ // Create a deeply nested schema
+ StringBuilder schemaBuilder = new StringBuilder();
+ StringBuilder instanceBuilder = new StringBuilder();
+
+ int depth = 50;
+
+ schemaBuilder.append("{\"properties\": {\"level0\": ");
+ instanceBuilder.append("{\"level0\": ");
+
+ for (int i = 0; i < depth; i++) {
+ schemaBuilder.append("{\"properties\": {\"level").append(i + 1).append("\": ");
+ instanceBuilder.append("{\"level").append(i + 1).append("\": ");
+ }
+
+ schemaBuilder.append("{\"type\": \"string\"}");
+ instanceBuilder.append("\"deepValue\"");
+
+ for (int i = 0; i <= depth; i++) {
+ schemaBuilder.append("}}");
+ instanceBuilder.append("}");
+ }
+
+ JsonValue schema = Json.parse(schemaBuilder.toString());
+ JsonValue validInstance = Json.parse(instanceBuilder.toString());
+
+ Jtd validator = new Jtd();
+
+ LOG.info(() -> "Testing depth: " + depth);
+
+ Jtd.Result validResult = validator.validate(schema, validInstance);
+ LOG.info(() -> "Deep valid result: " + validResult.isValid());
+ assertTrue(validResult.isValid(), "Deep nesting should work");
+
+ // Test invalid at deep level
+ JsonValue invalidInstance = Json.parse(instanceBuilder.toString().replace("\"deepValue\"", "123"));
+ Jtd.Result invalidResult = validator.validate(schema, invalidInstance);
+
+ LOG.info(() -> "Deep invalid result: " + !invalidResult.isValid());
+ assertFalse(invalidResult.isValid(), "Should detect error at deep level");
+ }
+
+ /// Test: Property count limits
+ @Test
+ public void probeLargeNumberOfProperties() {
+ // Schema with many properties
+ StringBuilder schemaBuilder = new StringBuilder("{\"properties\": {");
+ StringBuilder instanceBuilder = new StringBuilder("{");
+
+ int count = 100;
+
+ for (int i = 0; i < count; i++) {
+ if (i > 0) {
+ schemaBuilder.append(", ");
+ instanceBuilder.append(", ");
+ }
+ schemaBuilder.append("\"prop").append(i).append("\": {\"type\": \"string\"}");
+ instanceBuilder.append("\"prop").append(i).append("\": \"value").append(i).append("\"");
+ }
+
+ schemaBuilder.append("}}");
+ instanceBuilder.append("}");
+
+ JsonValue schema = Json.parse(schemaBuilder.toString());
+ JsonValue instance = Json.parse(instanceBuilder.toString());
+
+ Jtd validator = new Jtd();
+ Jtd.Result result = validator.validate(schema, instance);
+
+ LOG.info(() -> "Large property count (" + count + ") result: " + result.isValid());
+ assertTrue(result.isValid(), "Large number of properties should work");
+ }
+
+ /// Test: Property name collisions with prototype pollution concerns
+ @Test
+ public void probePrototypePollutionPropertyNames() {
+ JsonValue schema = Json.parse("{\"properties\": {}, \"additionalProperties\": false}");
+
+ // These are technically valid property names in JSON
+ String[] prototypeNames = {
+ "{\"__proto__\": 1}",
+ "{\"constructor\": 1}",
+ "{\"toString\": 1}",
+ "{\"hasOwnProperty\": 1}"
+ };
+
+ Jtd validator = new Jtd();
+
+ for (String nameJson : prototypeNames) {
+ try {
+ JsonValue instance = Json.parse(nameJson);
+ Jtd.Result result = validator.validate(schema, instance);
+
+ LOG.fine(() -> "Prototype name validation: " + nameJson + " -> valid=" + result.isValid());
+
+ // These should all be rejected as additional properties
+ assertFalse(result.isValid(), "Should reject: " + nameJson);
+ } catch (Exception e) {
+ LOG.warning(() -> "Issue with: " + nameJson + " - " + e.getMessage());
+ }
+ }
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java
new file mode 100644
index 0000000..e1f6b97
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java
@@ -0,0 +1,533 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Ref schema edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Forward references
+/// 2. Circular/recursive references
+/// 3. Nested references
+/// 4. Ref in different contexts (elements, values, properties)
+/// 5. Ref to different form types
+public class RefEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Forward reference resolution
+ /// Definitions can reference each other in any order
+ @Test
+ public void probeForwardReference() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "a": {"ref": "b"},
+ "b": {"type": "string"}
+ },
+ "ref": "a"
+ }
+ """);
+
+ JsonValue valid = Json.parse("\"test\"");
+ Jtd.Result result = new Jtd().validate(schema, valid);
+
+ LOG.info(() -> "Forward reference result: " + result.isValid());
+ assertTrue(result.isValid(), "Forward reference should resolve");
+ }
+
+ /// Test: Mutual recursion
+ /// Two definitions that reference each other
+ @Test
+ public void probeMutualRecursion() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "parent": {
+ "properties": {
+ "children": {"elements": {"ref": "child"}}
+ }
+ },
+ "child": {
+ "properties": {
+ "parent": {"ref": "parent"}
+ }
+ }
+ },
+ "ref": "parent"
+ }
+ """);
+
+ // This is a valid instance (empty children, no parent reference)
+ JsonValue valid = Json.parse("{\"children\": []}");
+ Jtd.Result result = new Jtd().validate(schema, valid);
+
+ LOG.info(() -> "Mutual recursion result: " + result.isValid());
+
+ // Should compile and validate
+ assertTrue(result.isValid(), "Mutual recursion should work");
+ }
+
+ /// Test: Deeply nested refs
+ @Test
+ public void probeDeeplyNestedRefs() {
+ // Create a chain of references
+ StringBuilder schemaBuilder = new StringBuilder();
+ schemaBuilder.append("{\"definitions\": {");
+
+ int depth = 50;
+
+ for (int i = 0; i < depth; i++) {
+ if (i > 0) schemaBuilder.append(", ");
+ schemaBuilder.append("\"level").append(i).append("\": {");
+ if (i < depth - 1) {
+ schemaBuilder.append("\"ref\": \"level").append(i + 1).append("\"");
+ } else {
+ schemaBuilder.append("\"type\": \"string\"");
+ }
+ schemaBuilder.append("}");
+ }
+
+ schemaBuilder.append("}, \"ref\": \"level0\"}");
+
+ JsonValue schema = Json.parse(schemaBuilder.toString());
+ JsonValue instance = Json.parse("\"test\"");
+
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Deeply nested refs (" + depth + "): " + result.isValid());
+ assertTrue(result.isValid(), "Deep reference chain should resolve");
+ }
+
+ /// Test: Ref in elements context
+ @Test
+ public void probeRefInElements() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "item": {"type": "string"}
+ },
+ "elements": {"ref": "item"}
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid
+ assertTrue(validator.validate(schema, Json.parse("[\"a\", \"b\", \"c\"]")).isValid());
+
+ // Invalid element
+ Jtd.Result result = validator.validate(schema, Json.parse("[\"a\", 123]"));
+ assertFalse(result.isValid(), "Should reject invalid element via ref");
+
+ LOG.info(() -> "Ref in elements: error count=" + result.errors().size());
+ }
+
+ /// Test: Ref in values context
+ @Test
+ public void probeRefInValues() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "value": {"type": "int32"}
+ },
+ "values": {"ref": "value"}
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid
+ assertTrue(validator.validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid());
+
+ // Invalid value
+ Jtd.Result result = validator.validate(schema, Json.parse("{\"a\": \"not-int\"}"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref in values: " + !result.isValid());
+ }
+
+ /// Test: Ref in properties context
+ @Test
+ public void probeRefInProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "address": {
+ "properties": {
+ "street": {"type": "string"},
+ "city": {"type": "string"}
+ }
+ }
+ },
+ "properties": {
+ "home": {"ref": "address"},
+ "work": {"ref": "address"}
+ }
+ }
+ """);
+
+ JsonValue valid = Json.parse("""
+ {
+ "home": {"street": "123 Main", "city": "Boston"},
+ "work": {"street": "456 Oak", "city": "NYC"}
+ }
+ """);
+
+ Jtd.Result result = new Jtd().validate(schema, valid);
+ assertTrue(result.isValid(), "Ref in properties should work");
+
+ LOG.info(() -> "Ref in properties: " + result.isValid());
+ }
+
+ /// Test: Ref to empty schema
+ @Test
+ public void probeRefToEmptySchema() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "anything": {}
+ },
+ "ref": "anything"
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Empty schema accepts anything
+ assertTrue(validator.validate(schema, Json.parse("null")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("123")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("\"string\"")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("[]")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("{}")).isValid());
+
+ LOG.info(() -> "Ref to empty schema accepts anything (correct)");
+ }
+
+ /// Test: Ref to discriminator
+ @Test
+ public void probeRefToDiscriminator() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "shape": {
+ "discriminator": "type",
+ "mapping": {
+ "circle": {"properties": {"radius": {"type": "float64"}}},
+ "square": {"properties": {"side": {"type": "float64"}}}
+ }
+ }
+ },
+ "ref": "shape"
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid circles
+ assertTrue(validator.validate(schema, Json.parse("{\"type\": \"circle\", \"radius\": 5.0}")).isValid());
+
+ // Valid squares
+ assertTrue(validator.validate(schema, Json.parse("{\"type\": \"square\", \"side\": 10.0}")).isValid());
+
+ // Invalid
+ Jtd.Result result = validator.validate(schema, Json.parse("{\"type\": \"unknown\"}"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to discriminator: " + !result.isValid());
+ }
+
+ /// Test: Ref to nullable schema
+ @Test
+ public void probeRefToNullableSchema() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "maybeString": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "properties": {
+ "field": {"ref": "maybeString"}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Valid: null
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid());
+
+ // Valid: string
+ assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid());
+
+ // Invalid: number
+ assertFalse(validator.validate(schema, Json.parse("{\"field\": 123}")).isValid());
+
+ LOG.info(() -> "Ref to nullable: test passed");
+ }
+
+ /// Test: Ref to elements
+ @Test
+ public void probeRefToElements() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "stringArray": {
+ "elements": {"type": "string"}
+ }
+ },
+ "ref": "stringArray"
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("[\"a\", \"b\"]")).isValid());
+
+ // Invalid
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("[1, 2, 3]"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to elements: " + !result.isValid());
+ }
+
+ /// Test: Ref to values
+ @Test
+ public void probeRefToValues() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "intMap": {
+ "values": {"type": "int32"}
+ }
+ },
+ "ref": "intMap"
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid());
+
+ // Invalid
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("{\"a\": \"string\"}"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to values: " + !result.isValid());
+ }
+
+ /// Test: Ref to properties
+ @Test
+ public void probeRefToProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "person": {
+ "properties": {
+ "name": {"type": "string"}
+ }
+ }
+ },
+ "ref": "person"
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("{\"name\": \"Alice\"}")).isValid());
+
+ // Invalid: missing required
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("{}"));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to properties: " + !result.isValid());
+ }
+
+ /// Test: Ref to enum
+ @Test
+ public void probeRefToEnum() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "color": {
+ "enum": ["red", "green", "blue"]
+ }
+ },
+ "ref": "color"
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("\"red\"")).isValid());
+
+ // Invalid
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("\"yellow\""));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to enum: " + !result.isValid());
+ }
+
+ /// Test: Ref to type
+ @Test
+ public void probeRefToType() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "id": {"type": "int32"}
+ },
+ "ref": "id"
+ }
+ """);
+
+ // Valid
+ assertTrue(new Jtd().validate(schema, Json.parse("42")).isValid());
+
+ // Invalid
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("\"not-int\""));
+ assertFalse(result.isValid());
+
+ LOG.info(() -> "Ref to type: " + !result.isValid());
+ }
+
+ /// Test: Unused definitions
+ @Test
+ public void probeUnusedDefinitions() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "used": {"type": "string"},
+ "unused": {"type": "int32"}
+ },
+ "ref": "used"
+ }
+ """);
+
+ // Should compile fine even with unused definition
+ JsonValue instance = Json.parse("\"test\"");
+ Jtd.Result result = new Jtd().validate(schema, instance);
+
+ LOG.info(() -> "Unused definitions: " + result.isValid());
+ assertTrue(result.isValid());
+ }
+
+ /// Test: Multiple refs to same definition
+ @Test
+ public void probeMultipleRefsToSameDefinition() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "nameType": {"type": "string"}
+ },
+ "properties": {
+ "firstName": {"ref": "nameType"},
+ "lastName": {"ref": "nameType"},
+ "middleName": {"ref": "nameType"}
+ }
+ }
+ """);
+
+ JsonValue instance = Json.parse("""
+ {
+ "firstName": "John",
+ "lastName": "Doe",
+ "middleName": "Q"
+ }
+ """);
+
+ Jtd.Result result = new Jtd().validate(schema, instance);
+ assertTrue(result.isValid());
+
+ LOG.info(() -> "Multiple refs to same def: " + result.isValid());
+ }
+
+ /// Test: Recursive ref with complex nesting
+ @Test
+ public void probeComplexRecursiveRef() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "tree": {
+ "properties": {
+ "value": {"type": "string"},
+ "left": {"nullable": true, "ref": "tree"},
+ "right": {"nullable": true, "ref": "tree"}
+ }
+ }
+ },
+ "ref": "tree"
+ }
+ """);
+
+ // Valid binary tree
+ JsonValue tree = Json.parse("""
+ {
+ "value": "root",
+ "left": {
+ "value": "left",
+ "left": null,
+ "right": null
+ },
+ "right": {
+ "value": "right",
+ "left": null,
+ "right": null
+ }
+ }
+ """);
+
+ Jtd.Result result = new Jtd().validate(schema, tree);
+ LOG.info(() -> "Complex recursive ref: " + result.isValid());
+ assertTrue(result.isValid());
+ }
+
+ /// Test: Ref in optionalProperties
+ @Test
+ public void probeRefInOptionalProperties() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "details": {"type": "string"}
+ },
+ "optionalProperties": {
+ "info": {"ref": "details"}
+ }
+ }
+ """);
+
+ Jtd validator = new Jtd();
+
+ // Missing - valid
+ assertTrue(validator.validate(schema, Json.parse("{}")).isValid());
+
+ // Present and valid
+ assertTrue(validator.validate(schema, Json.parse("{\"info\": \"test\"}")).isValid());
+
+ // Present but invalid type
+ assertFalse(validator.validate(schema, Json.parse("{\"info\": 123}")).isValid());
+
+ LOG.info(() -> "Ref in optionalProperties: passed");
+ }
+
+ /// Test: Ref resolution at multiple levels
+ @Test
+ public void probeMultiLevelRefResolution() {
+ JsonValue schema = Json.parse("""
+ {
+ "definitions": {
+ "level1": {"ref": "level2"},
+ "level2": {"ref": "level3"},
+ "level3": {"type": "boolean"}
+ },
+ "ref": "level1"
+ }
+ """);
+
+ assertTrue(new Jtd().validate(schema, Json.parse("true")).isValid());
+ assertFalse(new Jtd().validate(schema, Json.parse("\"not-bool\"")).isValid());
+
+ LOG.info(() -> "Multi-level ref resolution: passed");
+ }
+}
diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java
new file mode 100644
index 0000000..265adca
--- /dev/null
+++ b/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java
@@ -0,0 +1,351 @@
+package json.java21.jtd;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+/// Probes for Type validation edge cases and potential issues
+///
+/// Areas to probe:
+/// 1. Integer range boundary values
+/// 2. Integer vs float detection (3.0 is int, 3.1 is not)
+/// 3. Scientific notation handling
+/// 4. Very large numbers
+/// 5. Float vs double distinctions
+public class TypeValidationEdgeCaseProbe extends JtdTestBase {
+
+ /// Test: Integer type boundary values (exact min/max)
+ @Test
+ public void probeIntegerBoundaryValues() {
+ // int8 boundaries
+ testIntegerBoundary("int8", -128, 127);
+
+ // uint8 boundaries
+ testIntegerBoundary("uint8", 0, 255);
+
+ // int16 boundaries
+ testIntegerBoundary("int16", -32768, 32767);
+
+ // uint16 boundaries
+ testIntegerBoundary("uint16", 0, 65535);
+
+ // int32 boundaries
+ testIntegerBoundary("int32", -2147483648, 2147483647);
+
+ // uint32 boundaries
+ testIntegerBoundary("uint32", 0, 4294967295L);
+ }
+
+ private void testIntegerBoundary(String type, long min, long max) {
+ JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}");
+ Jtd validator = new Jtd();
+
+ // Min boundary - should be valid
+ JsonValue minValue = Json.parse(String.valueOf(min));
+ Jtd.Result minResult = validator.validate(schema, minValue);
+ assertTrue(minResult.isValid(), type + " should accept min value " + min);
+
+ // Max boundary - should be valid
+ JsonValue maxValue = Json.parse(String.valueOf(max));
+ Jtd.Result maxResult = validator.validate(schema, maxValue);
+ assertTrue(maxResult.isValid(), type + " should accept max value " + max);
+
+ // Min-1 - should be invalid
+ JsonValue belowMin = Json.parse(String.valueOf(min - 1));
+ Jtd.Result belowResult = validator.validate(schema, belowMin);
+ assertFalse(belowResult.isValid(), type + " should reject " + (min - 1));
+
+ // Max+1 - should be invalid
+ JsonValue aboveMax = Json.parse(String.valueOf(max + 1));
+ Jtd.Result aboveResult = validator.validate(schema, aboveMax);
+ assertFalse(aboveResult.isValid(), type + " should reject " + (max + 1));
+
+ LOG.fine(() -> type + " boundary check passed");
+ }
+
+ /// Test: Integer detection with fractional values
+ /// RFC 8927: "An integer value is a number without a fractional component"
+ @Test
+ public void probeIntegerFractionalDetection() {
+ JsonValue schema = Json.parse("{\"type\": \"int32\"}");
+ Jtd validator = new Jtd();
+
+ // These should be valid (zero fractional part)
+ String[] validIntegers = {"3.0", "3.00", "3.000", "0.0", "-5.0"};
+ for (String value : validIntegers) {
+ JsonValue instance = Json.parse(value);
+ Jtd.Result result = validator.validate(schema, instance);
+ assertTrue(result.isValid(), "int32 should accept integer representation: " + value);
+ }
+
+ // These should be invalid (non-zero fractional part)
+ String[] invalidIntegers = {"3.1", "3.01", "3.0001", "0.1", "-5.5"};
+ for (String value : invalidIntegers) {
+ JsonValue instance = Json.parse(value);
+ Jtd.Result result = validator.validate(schema, instance);
+ assertFalse(result.isValid(), "int32 should reject fractional value: " + value);
+ }
+
+ LOG.info(() -> "Integer fractional detection probe passed");
+ }
+
+ /// Test: Scientific notation handling
+ /// Scientific notation that represents integers should be valid
+ @Test
+ public void probeScientificNotationIntegers() {
+ JsonValue schema = Json.parse("{\"type\": \"int32\"}");
+ Jtd validator = new Jtd();
+
+ // Valid scientific notation integers
+ String[] validScientific = {"1e2", "1E2", "1e+2", "1e-2", "-5e1"};
+ // 1e2 = 100, 1e-2 = 0.01 (not integer!)
+
+ // These represent integers
+ JsonValue int1 = Json.parse("1e2"); // 100
+ Jtd.Result r1 = validator.validate(schema, int1);
+ LOG.info(() -> "1e2 (should be 100): valid=" + r1.isValid());
+
+ // These do NOT represent integers
+ JsonValue notInt = Json.parse("1.5e0"); // 1.5
+ Jtd.Result r2 = validator.validate(schema, notInt);
+ assertFalse(r2.isValid(), "1.5 should not be a valid int32");
+
+ JsonValue alsoNotInt = Json.parse("1e-1"); // 0.1
+ Jtd.Result r3 = validator.validate(schema, alsoNotInt);
+ LOG.info(() -> "1e-1 (0.1): valid=" + r3.isValid());
+ }
+
+ /// Test: Float types accept any number
+ /// RFC 8927: float32 and float64 accept any JSON number
+ @Test
+ public void probeFloatTypesAcceptAnyNumber() {
+ Jtd validator = new Jtd();
+
+ JsonValue schema32 = Json.parse("{\"type\": \"float32\"}");
+ JsonValue schema64 = Json.parse("{\"type\": \"float64\"}");
+
+ // Various number formats
+ String[] numbers = {
+ "1", "-1", "0",
+ "1.5", "-3.14159", "0.0001",
+ "1e10", "1e-10", "-5E+5",
+ "1.7976931348623157e308", // Near double max
+ "-1.7976931348623157e308"
+ };
+
+ for (String num : numbers) {
+ try {
+ JsonValue instance = Json.parse(num);
+ Jtd.Result r32 = validator.validate(schema32, instance);
+ Jtd.Result r64 = validator.validate(schema64, instance);
+
+ LOG.fine(() -> num + " -> float32:" + r32.isValid() + " float64:" + r64.isValid());
+
+ assertTrue(r32.isValid(), "float32 should accept: " + num);
+ assertTrue(r64.isValid(), "float64 should accept: " + num);
+ } catch (Exception e) {
+ LOG.warning(() -> "Failed to parse: " + num + " - " + e.getMessage());
+ }
+ }
+ }
+
+ /// Test: Non-numbers rejected by float types
+ @Test
+ public void probeFloatTypesRejectNonNumbers() {
+ Jtd validator = new Jtd();
+
+ JsonValue schema = Json.parse("{\"type\": \"float32\"}");
+
+ // Non-number values
+ String[] nonNumbers = {"\"string\"", "true", "false", "null", "[]", "{}"};
+
+ for (String value : nonNumbers) {
+ JsonValue instance = Json.parse(value);
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid(), "float32 should reject: " + value);
+ }
+ }
+
+ /// Test: Timestamp format variations
+ /// RFC 3339 has many valid formats
+ @Test
+ public void probeTimestampFormatVariations() {
+ JsonValue schema = Json.parse("{\"type\": \"timestamp\"}");
+ Jtd validator = new Jtd();
+
+ // Valid RFC 3339 formats
+ String[] validTimestamps = {
+ "\"2023-01-01T00:00:00Z\"",
+ "\"2023-01-01T00:00:00.000Z\"",
+ "\"2023-01-01T00:00:00+00:00\"",
+ "\"2023-01-01T00:00:00-00:00\"",
+ "\"2023-01-01T00:00:00+05:30\"",
+ "\"2023-01-01T00:00:00-08:00\"",
+ "\"2023-12-31T23:59:60Z\"", // Leap second
+ "\"2020-02-29T12:00:00Z\"", // Leap year
+ };
+
+ for (String ts : validTimestamps) {
+ try {
+ JsonValue instance = Json.parse(ts);
+ Jtd.Result result = validator.validate(schema, instance);
+
+ LOG.fine(() -> ts + " -> valid=" + result.isValid());
+
+ if (!result.isValid()) {
+ LOG.warning(() -> "Timestamp rejected: " + ts);
+ }
+ } catch (Exception e) {
+ LOG.warning(() -> "Failed: " + ts + " - " + e.getMessage());
+ }
+ }
+ }
+
+ /// Test: Invalid timestamp formats
+ @Test
+ public void probeInvalidTimestampFormats() {
+ JsonValue schema = Json.parse("{\"type\": \"timestamp\"}");
+ Jtd validator = new Jtd();
+
+ // Invalid formats
+ String[] invalidTimestamps = {
+ "\"2023-01-01\"", // Date only
+ "\"12:00:00\"", // Time only
+ "\"2023/01/01T12:00:00Z\"", // Wrong date separator
+ "\"2023-01-01 12:00:00Z\"", // Space instead of T
+ "\"2023-1-1T12:00:00Z\"", // Single digit month/day
+ "\"2023-01-01T12:00Z\"", // Missing seconds
+ "\"2023-01-01T25:00:00Z\"", // Invalid hour
+ "\"2023-01-01T12:61:00Z\"", // Invalid minute
+ "\"2023-01-01T12:00:61Z\"", // Invalid second (not leap second)
+ "\"not-a-timestamp\"",
+ "123",
+ "null"
+ };
+
+ for (String ts : invalidTimestamps) {
+ try {
+ JsonValue instance = Json.parse(ts);
+ Jtd.Result result = validator.validate(schema, instance);
+
+ assertFalse(result.isValid(), "Should reject invalid timestamp: " + ts);
+ } catch (Exception e) {
+ LOG.warning(() -> "Parse/validation failed for: " + ts);
+ }
+ }
+ }
+
+ /// Test: Boolean type rejects all non-booleans
+ @Test
+ public void probeBooleanTypeStrictness() {
+ JsonValue schema = Json.parse("{\"type\": \"boolean\"}");
+ Jtd validator = new Jtd();
+
+ // Valid booleans
+ assertTrue(validator.validate(schema, Json.parse("true")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("false")).isValid());
+
+ // Invalid values
+ String[] invalid = {"\"true\"", "\"false\"", "1", "0", "null", "[]", "{}"};
+ for (String value : invalid) {
+ JsonValue instance = Json.parse(value);
+ Jtd.Result result = validator.validate(schema, instance);
+ assertFalse(result.isValid(), "Boolean should reject: " + value);
+ }
+ }
+
+ /// Test: String type rejects non-strings
+ @Test
+ public void probeStringTypeStrictness() {
+ JsonValue schema = Json.parse("{\"type\": \"string\"}");
+ Jtd validator = new Jtd();
+
+ // Valid strings
+ assertTrue(validator.validate(schema, Json.parse("\"hello\"")).isValid());
+ assertTrue(validator.validate(schema, Json.parse("\"\"")).isValid()); // Empty string
+ assertTrue(validator.validate(schema, Json.parse("\"with spaces\"")).isValid());
+
+ // Invalid values
+ String[] invalid = {"123", "true", "null", "[]", "{}"};
+ for (String value : invalid) {
+ JsonValue instance = Json.parse(value);
+ Jtd.Result result = validator.validate(schema, instance);
+ assertFalse(result.isValid(), "String should reject: " + value);
+ }
+ }
+
+ /// Test: Very large integers
+ /// Numbers larger than 64-bit should be handled gracefully
+ @Test
+ public void probeVeryLargeIntegers() {
+ JsonValue schema = Json.parse("{\"type\": \"uint32\"}");
+ Jtd validator = new Jtd();
+
+ // Very large number (bigger than uint32 max)
+ JsonValue huge = Json.parse("999999999999999999999999999999");
+ Jtd.Result result = validator.validate(schema, huge);
+
+ LOG.info(() -> "Very large number result: " + result.isValid());
+
+ // Should be invalid (out of range)
+ assertFalse(result.isValid(), "Should reject huge number");
+ }
+
+ /// Test: Zero values for all integer types
+ @Test
+ public void probeZeroValuesForAllIntegerTypes() {
+ String[] types = {"int8", "uint8", "int16", "uint16", "int32", "uint32"};
+
+ for (String type : types) {
+ JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}");
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("0"));
+
+ boolean expectValid = !type.startsWith("u") || true; // 0 is valid for all
+
+ LOG.fine(() -> type + " with 0: " + result.isValid() + " (expected: " + expectValid + ")");
+
+ if (expectValid) {
+ assertTrue(result.isValid(), type + " should accept 0");
+ }
+ }
+ }
+
+ /// Test: Negative values for unsigned types
+ @Test
+ public void probeNegativeValuesForUnsignedTypes() {
+ String[] unsignedTypes = {"uint8", "uint16", "uint32"};
+
+ for (String type : unsignedTypes) {
+ JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}");
+ Jtd.Result result = new Jtd().validate(schema, Json.parse("-1"));
+
+ LOG.fine(() -> type + " with -1: valid=" + result.isValid());
+
+ assertFalse(result.isValid(), type + " should reject negative values");
+ }
+ }
+
+ /// Test: Type coercion edge cases
+ /// Ensure no implicit type coercion happens
+ @Test
+ public void probeNoTypeCoercion() {
+ JsonValue intSchema = Json.parse("{\"type\": \"int32\"}");
+ Jtd validator = new Jtd();
+
+ // String representation of number should NOT be accepted
+ JsonValue stringNumber = Json.parse("\"42\"");
+ Jtd.Result result = validator.validate(intSchema, stringNumber);
+
+ assertFalse(result.isValid(), "String \"42\" should not be accepted as int32");
+
+ // Boolean should NOT be accepted
+ JsonValue bool = Json.parse("true");
+ Jtd.Result boolResult = validator.validate(intSchema, bool);
+
+ assertFalse(boolResult.isValid(), "Boolean should not be accepted as int32");
+ }
+}
diff --git a/pom.xml b/pom.xml
index 16d8439..79c9d99 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,6 +42,7 @@
json-compatibility-suite
json-java21-jtd
json-java21-jsonpath
+ json-java21-jtd-codegen