diff --git a/.cz.toml b/.cz.toml
index ab20c79..038a032 100644
--- a/.cz.toml
+++ b/.cz.toml
@@ -2,5 +2,5 @@
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "semver"
-version = "2.0.1"
+version = "3.0.0"
update_changelog_on_bump = true
diff --git a/README.md b/README.md
index cede22f..be61522 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
# Blue Language Java
Java implementation of the Blue language core:
-https://language.blue/docs/reference/specification
+https://github.com/bluecontract/blue-spec
-Blue is a deterministic document language for describing data, types, identity,
-and document-processing behavior. A Blue document can be parsed, resolved
-against its type graph, reduced to canonical content, and addressed by a stable
-content hash called a BlueId.
+Blue is a deterministic document language for describing data, types, and
+identity. A Blue document can be parsed, resolved against its type graph,
+reduced to canonical content, and addressed by a stable content hash called a
+BlueId. Blue Contracts and Processor 1.0 is implemented as a separate runtime
+target on top of the language layer for document processing, channels, handlers,
+events, gas, checkpoints, embedded scopes, lifecycle, and termination.
This library gives Java applications the foundations needed to work with Blue:
@@ -21,6 +23,9 @@ This library gives Java applications the foundations needed to work with Blue:
- run the generic snapshot-backed document processor;
- register custom channel, handler, and marker processors.
+Blue Language 1.0 and Blue Contracts and Processor 1.0 have separate
+conformance suites and reports.
+
## Installation
Gradle:
@@ -31,7 +36,7 @@ repositories {
}
dependencies {
- implementation "blue.language:blue-language-java:1.0.0"
+ implementation "blue.language:blue-language-java:3.0.0"
}
```
@@ -41,24 +46,10 @@ Maven:
blue.language
blue-language-java
- 1.0.0
+ 3.0.0
```
-For local development before the `1.0.0` release is available from Maven
-Central, publish this checkout locally and depend on the snapshot version
-reported by Gradle. After the 1.0 bump, that coordinate is:
-
-```bash
-./gradlew publishToMavenLocal
-```
-
-```groovy
-dependencies {
- implementation "blue.language:blue-language-java:1.0.0-SNAPSHOT"
-}
-```
-
## Core Concepts
### Nodes
@@ -263,7 +254,6 @@ reference forms.
`schema` provides deterministic core validation. Supported keywords include:
- `required`
-- `allowMultiple`
- `minLength`
- `maxLength`
- `minimum`
@@ -577,6 +567,39 @@ The generalization flow is transactional:
4. commit the new snapshot only if the whole plan succeeds;
5. roll back on failure.
+## Working Documents
+
+`WorkingDocument` is a frozen preview state for processor-side read-your-writes
+logic. It uses the same immutable patch transaction as the processor runtime,
+including conformance checks, dynamic type generalization, and Type
+Generalization Policy enforcement, but it does not commit to the active
+processor runtime.
+
+```java
+import blue.language.processor.ProcessorExecutionContext;
+import blue.language.processor.WorkingDocument;
+import blue.language.processor.model.JsonPatch;
+
+WorkingDocument working = context.newWorkingDocument();
+
+working.applyPatch(JsonPatch.replace("/price/currency", new Node().value("USD")));
+
+String currency = (String) working.resolvedAt("/price/currency").getValue();
+```
+
+Working previews do not emit Document Update cascades, charge gas, update
+checkpoints, or write termination/marker state. Contract processors should
+preview first and buffer actual effects only after preview succeeds:
+
+```java
+working.applyPatches(patches);
+context.applyPatches(patches);
+```
+
+Use `materializeCanonicalRoot()`, `materializeResolvedRoot()`, `commitToNode()`,
+or `commitSnapshot()` only at explicit integration boundaries. Normal processor
+reads should stay on `FrozenNode` roots and pointer lookups.
+
## Object Mapping
Java objects can be converted to and from Blue nodes.
@@ -635,10 +658,8 @@ Processor roles:
Minimal channel contract:
```java
-import blue.language.model.TypeBlueId;
import blue.language.processor.model.ChannelContract;
-@TypeBlueId("ExampleChannel")
public class ExampleChannel extends ChannelContract {
private String eventType;
@@ -682,10 +703,8 @@ public final class ExampleChannelProcessor implements ChannelProcessor PROCESSOR_MANAGED_TYPE_BLUE_IDS = new HashSet<>(Arrays.asList(
- "ChannelEventCheckpoint",
- "DocumentUpdate",
- "DocumentUpdateChannel",
- "EmbeddedNodeChannel",
- "InitializationMarker",
- "JsonPatch",
- "LifecycleChannel",
- "ProcessEmbedded",
- "ProcessingFailureMarker",
- "ProcessingTerminatedMarker",
- "TriggeredEventChannel"
- ));
+ private static final int RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT = 32;
private NodeProvider nodeProvider;
+ private NodeProvider originalNodeProvider;
private MergingProcessor mergingProcessor;
private TypeClassResolver typeClassResolver;
private Map preprocessingAliases = new HashMap<>();
private Limits globalLimits = NO_LIMITS;
private DocumentProcessor documentProcessor;
private final ConcurrentMap resolvedSnapshotsByBlueId = new ConcurrentHashMap<>();
+ private final ConcurrentMap externalContractTypeNodes = new ConcurrentHashMap<>();
+ private final List recentProcessingDocumentSnapshots = new ArrayList<>();
private final ResolvedReferenceCache resolvedReferenceCache = new ResolvedReferenceCache();
private final DictionaryRegistry dictionaryRegistry = new DictionaryRegistry();
@@ -80,6 +81,7 @@ public Blue() {
}
public Blue(NodeProvider nodeProvider) {
+ this.originalNodeProvider = nodeProvider;
this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider);
this.mergingProcessor = createDefaultNodeProcessor();
this.documentProcessor = createDefaultDocumentProcessor();
@@ -94,6 +96,7 @@ public Blue(NodeProvider nodeProvider, TypeClassResolver typeClassResolver) {
}
public Blue(NodeProvider nodeProvider, MergingProcessor mergingProcessor, TypeClassResolver typeClassResolver) {
+ this.originalNodeProvider = nodeProvider;
this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider);
this.mergingProcessor = mergingProcessor != null ? mergingProcessor : createDefaultNodeProcessor();
this.typeClassResolver = typeClassResolver;
@@ -157,10 +160,22 @@ public Node resolvePreservingMatchingPaths(Node node,
return resolvePreservingPaths(node, limits, selectPaths(node, pathPatterns, predicate));
}
+ /**
+ * @deprecated Use {@link #canonicalize(Node)} for Content BlueId identity
+ * or {@link MergeReverser#reverseToMinimizedOverlay(Node)} for author-facing
+ * minimized output.
+ */
+ @Deprecated
public Node reverse(Node node) {
return new MergeReverser().reverse(node);
}
+ /**
+ * @deprecated Use {@link #canonicalize(Object)} for Content BlueId identity
+ * or {@link MergeReverser#reverseToMinimizedOverlay(Node)} for author-facing
+ * minimized output.
+ */
+ @Deprecated
public Node reverse(Object object) {
return reverse(objectToNode(object));
}
@@ -168,17 +183,39 @@ public Node reverse(Object object) {
public Node canonicalize(Node node) {
Node preprocessed = preprocess(node.clone());
Node resolved = resolve(preprocessed.clone());
- return reverse(resolved);
+ return new MergeReverser().reverseToCanonicalOverlay(resolved);
}
public Node canonicalize(Object object) {
return canonicalize(objectToNode(object));
}
+ public Node expand(Node node) {
+ if (node == null) {
+ throw new IllegalArgumentException("node must not be null");
+ }
+ return expandReferences(node);
+ }
+
+ public Node expand(Object object) {
+ return expand(objectToNode(object));
+ }
+
+ public Node collapse(Node node) {
+ if (node == null) {
+ throw new IllegalArgumentException("node must not be null");
+ }
+ return new Node().blueId(BlueIdCalculator.calculateBlueId(node));
+ }
+
+ public Node collapse(Object object) {
+ return collapse(objectToNode(object));
+ }
+
public ResolvedSnapshot resolveToSnapshot(Node node) {
Node preprocessed = preprocess(node.clone());
Node resolved = resolve(preprocessed.clone());
- Node canonical = reverse(resolved.clone());
+ Node canonical = new MergeReverser().reverseToCanonicalOverlay(resolved.clone());
FrozenNode canonicalRoot = FrozenNode.fromNode(canonical);
return cacheSnapshot(new ResolvedSnapshot(canonicalRoot, resolvedReferenceCache.freezeResolved(resolved), canonicalRoot.blueId()));
}
@@ -226,6 +263,75 @@ private List providerContentWithoutRootIdentity(List nodes) {
return canonical;
}
+ private Node expandReferences(Node node) {
+ if (node == null) {
+ return null;
+ }
+ if (node.isReferenceOnly()) {
+ List nodes = nodeProvider.fetchByBlueId(node.getBlueId());
+ if (nodes == null || nodes.isEmpty()) {
+ throw new IllegalArgumentException("No content found for blueId: " + node.getBlueId());
+ }
+ if (nodes.size() == 1) {
+ return expandReferences(providerContentWithoutRootIdentity(nodes.get(0)));
+ }
+ return new Node().items(expandReferences(providerContentWithoutRootIdentity(nodes)));
+ }
+
+ Node expanded = node.clone();
+ expanded.type(expandReferences(expanded.getType()));
+ expanded.itemType(expandReferences(expanded.getItemType()));
+ expanded.keyType(expandReferences(expanded.getKeyType()));
+ expanded.valueType(expandReferences(expanded.getValueType()));
+ expanded.blue(expandReferences(expanded.getBlue()));
+ expanded.contracts(expandReferences(expanded.getContracts()));
+ if (expanded.getItems() != null) {
+ expanded.items(expandReferences(expanded.getItems()));
+ }
+ if (expanded.getProperties() != null) {
+ Map expandedProperties = new LinkedHashMap<>();
+ expanded.getProperties().forEach((key, value) ->
+ expandedProperties.put(key, expandReferences(value)));
+ expanded.properties(expandedProperties);
+ }
+ if (expanded.getSchema() != null) {
+ expanded.schema(expandReferences(expanded.getSchema()));
+ }
+ return expanded;
+ }
+
+ private List expandReferences(List nodes) {
+ List expanded = new ArrayList<>(nodes.size());
+ for (Node node : nodes) {
+ expanded.add(expandReferences(node));
+ }
+ return expanded;
+ }
+
+ private Schema expandReferences(Schema schema) {
+ if (schema == null) {
+ return null;
+ }
+ Schema expanded = schema.clone();
+ expanded.required(expandReferences(expanded.getRequired()));
+ expanded.minLength(expandReferences(expanded.getMinLength()));
+ expanded.maxLength(expandReferences(expanded.getMaxLength()));
+ expanded.minimum(expandReferences(expanded.getMinimum()));
+ expanded.maximum(expandReferences(expanded.getMaximum()));
+ expanded.exclusiveMinimum(expandReferences(expanded.getExclusiveMinimum()));
+ expanded.exclusiveMaximum(expandReferences(expanded.getExclusiveMaximum()));
+ expanded.multipleOf(expandReferences(expanded.getMultipleOf()));
+ expanded.minItems(expandReferences(expanded.getMinItems()));
+ expanded.maxItems(expandReferences(expanded.getMaxItems()));
+ expanded.uniqueItems(expandReferences(expanded.getUniqueItems()));
+ expanded.minFields(expandReferences(expanded.getMinFields()));
+ expanded.maxFields(expandReferences(expanded.getMaxFields()));
+ if (expanded.getEnum() != null) {
+ expanded.enumValues(expandReferences(expanded.getEnum()));
+ }
+ return expanded;
+ }
+
public CanonicalOverlayPatchEngine canonicalPatchEngine(Node canonical) {
return new CanonicalOverlayPatchEngine(FrozenNode.fromNode(canonical));
}
@@ -262,6 +368,9 @@ public int resolvedReferenceCacheSize() {
public void clearResolvedSnapshotCache() {
resolvedSnapshotsByBlueId.clear();
+ synchronized (recentProcessingDocumentSnapshots) {
+ recentProcessingDocumentSnapshots.clear();
+ }
resolvedReferenceCache.clear();
}
@@ -269,6 +378,49 @@ public ConformanceEngine conformanceEngine() {
return new ConformanceEngine(nodeProvider, mergingProcessor, resolvedReferenceCache);
}
+ public String languageVersion() {
+ return "1.0";
+ }
+
+ public BlueConformanceReport conformanceReport() {
+ String fixturePackageIdentity = BlueConformanceReport.loadFixturePackageIdentity("blue-language-1.0-fixtures:unavailable");
+ List fixtureIds = BlueConformanceReport.loadFixtureIds();
+ Map fixtureCategories = BlueConformanceReport.loadFixtureCategories();
+ return new BlueConformanceReport(
+ languageVersion(),
+ new LinkedHashMap<>(BlueCoreTypeRegistry.INSTANCE.blueIdsByName()),
+ fixturePackageIdentity,
+ fixtureIds,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ fixtureCategories
+ );
+ }
+
+ public BlueConformanceReport runConformanceSuite() {
+ return BlueConformanceSuiteRunner.run(this);
+ }
+
+ public BlueContractsConformanceReport contractsConformanceReport() {
+ String fixturePackageIdentity = BlueContractsConformanceReport.loadFixturePackageIdentity(
+ "blue-contracts-1.0-fixtures:unavailable");
+ List fixtureIds = BlueContractsConformanceReport.loadFixtureIds();
+ Map fixtureCategories =
+ BlueContractsConformanceReport.loadFixtureCategories();
+ return new BlueContractsConformanceReport(
+ languageVersion(),
+ fixturePackageIdentity,
+ fixtureIds,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ fixtureCategories,
+ Collections.emptyList());
+ }
+
+ public BlueContractsConformanceReport runContractsConformanceSuite() {
+ return BlueContractsConformanceSuiteRunner.run(this);
+ }
+
public void extend(Node node, Limits limits) {
Limits effectiveLimits = combineWithGlobalLimits(limits);
new NodeExtender(nodeProvider).extend(node, effectiveLimits);
@@ -304,11 +456,94 @@ public Limits getGlobalLimits() {
}
public Node yamlToNode(String yaml) {
- return preprocess(YAML_MAPPER.readValue(yaml, Node.class));
+ return preprocess(parseSourceYaml(yaml));
}
public Node jsonToNode(String json) {
- return preprocess(JSON_MAPPER.readValue(json, Node.class));
+ return preprocess(parseSourceJson(json));
+ }
+
+ public Node parseSourceYaml(String yaml) {
+ return YAML_MAPPER.readValue(yaml, Node.class);
+ }
+
+ public Node parseSourceJson(String json) {
+ return JSON_MAPPER.readValue(json, Node.class);
+ }
+
+ public Node parseBlueIdInputYaml(String yaml) {
+ Node node = YAML_MAPPER.readValue(yaml, Node.class);
+ validateBlueIdInputReferences(node, "/");
+ BlueIdCalculator.calculateBlueId(node);
+ return node;
+ }
+
+ public Node parseBlueIdInputJson(String json) {
+ Node node = JSON_MAPPER.readValue(json, Node.class);
+ validateBlueIdInputReferences(node, "/");
+ BlueIdCalculator.calculateBlueId(node);
+ return node;
+ }
+
+ private void validateBlueIdInputReferences(Node node, String path) {
+ if (node == null) {
+ return;
+ }
+ if (node.getBlueId() != null) {
+ BlueIds.requireNoThisPlaceholderOutsideCyclicApi(node.getBlueId(), path + "/blueId");
+ BlueIds.requireBlueIdOrCyclicMember(node.getBlueId(), path + "/blueId");
+ }
+ if (node.getPreviousBlueId() != null) {
+ BlueIds.requirePlainBlueId(node.getPreviousBlueId(), path + "/$previous/blueId");
+ }
+ validateBlueIdInputReferences(node.getType(), appendPath(path, "type"));
+ validateBlueIdInputReferences(node.getItemType(), appendPath(path, "itemType"));
+ validateBlueIdInputReferences(node.getKeyType(), appendPath(path, "keyType"));
+ validateBlueIdInputReferences(node.getValueType(), appendPath(path, "valueType"));
+ validateBlueIdInputReferences(node.getBlue(), appendPath(path, "blue"));
+ validateBlueIdInputReferences(node.getContracts(), appendPath(path, "contracts"));
+ if (node.getItems() != null) {
+ for (int i = 0; i < node.getItems().size(); i++) {
+ validateBlueIdInputReferences(node.getItems().get(i), appendPath(path, String.valueOf(i)));
+ }
+ }
+ if (node.getProperties() != null) {
+ node.getProperties().forEach((key, value) ->
+ validateBlueIdInputReferences(value, appendPath(path, key)));
+ }
+ validateBlueIdInputReferences(node.getSchema(), appendPath(path, "schema"));
+ }
+
+ private void validateBlueIdInputReferences(Schema schema, String path) {
+ if (schema == null) {
+ return;
+ }
+ validateBlueIdInputReferences(schema.getRequired(), appendPath(path, "required"));
+ validateBlueIdInputReferences(schema.getMinLength(), appendPath(path, "minLength"));
+ validateBlueIdInputReferences(schema.getMaxLength(), appendPath(path, "maxLength"));
+ validateBlueIdInputReferences(schema.getMinimum(), appendPath(path, "minimum"));
+ validateBlueIdInputReferences(schema.getMaximum(), appendPath(path, "maximum"));
+ validateBlueIdInputReferences(schema.getExclusiveMinimum(), appendPath(path, "exclusiveMinimum"));
+ validateBlueIdInputReferences(schema.getExclusiveMaximum(), appendPath(path, "exclusiveMaximum"));
+ validateBlueIdInputReferences(schema.getMultipleOf(), appendPath(path, "multipleOf"));
+ validateBlueIdInputReferences(schema.getMinItems(), appendPath(path, "minItems"));
+ validateBlueIdInputReferences(schema.getMaxItems(), appendPath(path, "maxItems"));
+ validateBlueIdInputReferences(schema.getUniqueItems(), appendPath(path, "uniqueItems"));
+ validateBlueIdInputReferences(schema.getMinFields(), appendPath(path, "minFields"));
+ validateBlueIdInputReferences(schema.getMaxFields(), appendPath(path, "maxFields"));
+ if (schema.getEnum() != null) {
+ for (int i = 0; i < schema.getEnum().size(); i++) {
+ validateBlueIdInputReferences(schema.getEnum().get(i), appendPath(path, "enum/" + i));
+ }
+ }
+ }
+
+ private String appendPath(String path, String segment) {
+ String prefix = path == null || path.isEmpty() ? "/" : path;
+ if ("/".equals(prefix)) {
+ return "/" + segment.replace("~", "~0").replace("/", "~1");
+ }
+ return prefix + "/" + segment.replace("~", "~0").replace("/", "~1");
}
public String nodeToYaml(Node node) {
@@ -430,10 +665,27 @@ public Blue registerContractProcessor(String blueId, ContractProcessor extends
return this;
}
+ public Blue registerContractProcessor(String blueId,
+ Node canonicalTypeNode,
+ ContractProcessor extends Contract> processor) {
+ return registerExternalContractType(blueId, canonicalTypeNode, processor);
+ }
+
+ public Blue registerExternalContractType(String blueId,
+ Node canonicalTypeNode,
+ ContractProcessor extends Contract> processor) {
+ registerExternalTypeNode(blueId, canonicalTypeNode);
+ return registerContractProcessor(blueId, processor);
+ }
+
public DocumentProcessingResult processDocument(Node document, Node event) {
DocumentProcessor processor = ensureDocumentProcessor();
long start = System.nanoTime();
try {
+ ResolvedSnapshot cached = cachedProcessingSnapshotFor(document, processor);
+ if (cached != null) {
+ return rememberProcessingResultSnapshot(processor.processDocument(cached, event));
+ }
return attachProcessingSnapshot(processor, processor.processDocument(document, event));
} finally {
processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start);
@@ -444,7 +696,7 @@ public DocumentProcessingResult processDocument(ResolvedSnapshot snapshot, Node
DocumentProcessor processor = ensureDocumentProcessor();
long start = System.nanoTime();
try {
- return processor.processDocument(snapshot, event);
+ return rememberProcessingResultSnapshot(processor.processDocument(snapshot, event));
} finally {
processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start);
}
@@ -468,7 +720,7 @@ public DocumentProcessingResult initializeDocument(Node document) {
}
public DocumentProcessingResult initializeDocument(ResolvedSnapshot snapshot) {
- return ensureDocumentProcessor().initializeDocument(snapshot);
+ return rememberProcessingResultSnapshot(ensureDocumentProcessor().initializeDocument(snapshot));
}
public boolean isInitialized(Node document) {
@@ -533,6 +785,7 @@ public Map getPreprocessingAliases() {
}
public Blue nodeProvider(NodeProvider nodeProvider) {
+ this.originalNodeProvider = nodeProvider;
this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider);
clearResolvedSnapshotCache();
refreshDocumentProcessorConformanceEngine();
@@ -565,19 +818,24 @@ private DocumentProcessor ensureDocumentProcessor() {
private DocumentProcessor createDefaultDocumentProcessor() {
return DocumentProcessor.builder()
- .withConformanceEngine(conformanceEngine())
+ .withConformanceEngine(processorConformanceEngine())
.withSnapshotManager(processingSnapshotManager())
.withMatchingService(new ContractMatchingService(this))
.build();
}
+ private ConformanceEngine processorConformanceEngine() {
+ return new ConformanceEngine(processorSnapshotNodeProvider(), mergingProcessor, resolvedReferenceCache);
+ }
+
private DocumentProcessingResult attachProcessingSnapshot(DocumentProcessor processor, DocumentProcessingResult result) {
if (result == null || result.capabilityFailure() || result.snapshot() != null) {
- return result;
+ return rememberProcessingResultSnapshot(result);
}
long start = System.nanoTime();
try {
- return result.withSnapshot(resolveProcessingSnapshot(result.document()));
+ DocumentProcessingResult attached = result.withSnapshot(resolveProcessingSnapshot(result.document()));
+ return rememberProcessingResultSnapshot(attached);
} finally {
long nanos = System.nanoTime() - start;
processor.processingMetricsSink().addResultSnapshotAttachNanos(nanos);
@@ -585,11 +843,85 @@ private DocumentProcessingResult attachProcessingSnapshot(DocumentProcessor proc
}
}
+ private DocumentProcessingResult rememberProcessingResultSnapshot(DocumentProcessingResult result) {
+ if (result != null && result.snapshot() != null && result.document() != null) {
+ rememberProcessingSnapshot(result.document(), result.snapshot());
+ }
+ return result;
+ }
+
+ private ResolvedSnapshot cachedProcessingSnapshotFor(Node document, DocumentProcessor processor) {
+ if (document == null || !processor.supportsSnapshotProcessing()) {
+ return null;
+ }
+ long start = System.nanoTime();
+ ProcessingMetricsSink metrics = processor.processingMetricsSink();
+ try {
+ ResolvedSnapshot identitySnapshot = recentProcessingSnapshotByIdentity(document);
+ if (identitySnapshot == null) {
+ metrics.incrementProcessingSnapshotCacheMisses();
+ return null;
+ }
+ String blueId;
+ try {
+ blueId = BlueIdCalculator.calculateUncheckedBlueId(document);
+ } catch (RuntimeException ex) {
+ metrics.incrementProcessingSnapshotCacheMisses();
+ return null;
+ }
+ ResolvedSnapshot cached = blueId.equals(identitySnapshot.blueId()) ? identitySnapshot : null;
+ if (cached != null) {
+ metrics.incrementProcessingSnapshotCacheHits();
+ return cached;
+ }
+ metrics.incrementProcessingSnapshotCacheMisses();
+ return null;
+ } finally {
+ metrics.addProcessingSnapshotCacheLookupNanos(System.nanoTime() - start);
+ }
+ }
+
+ private ResolvedSnapshot recentProcessingSnapshotByIdentity(Node document) {
+ synchronized (recentProcessingDocumentSnapshots) {
+ for (ProcessingDocumentSnapshot entry : recentProcessingDocumentSnapshots) {
+ if (entry.document == document) {
+ return entry.snapshot;
+ }
+ }
+ }
+ return null;
+ }
+
+ private void rememberProcessingSnapshot(Node document, ResolvedSnapshot snapshot) {
+ synchronized (recentProcessingDocumentSnapshots) {
+ for (int i = recentProcessingDocumentSnapshots.size() - 1; i >= 0; i--) {
+ ProcessingDocumentSnapshot entry = recentProcessingDocumentSnapshots.get(i);
+ if (entry.document == document || entry.snapshot.blueId().equals(snapshot.blueId())) {
+ recentProcessingDocumentSnapshots.remove(i);
+ }
+ }
+ recentProcessingDocumentSnapshots.add(0, new ProcessingDocumentSnapshot(document, snapshot));
+ while (recentProcessingDocumentSnapshots.size() > RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT) {
+ recentProcessingDocumentSnapshots.remove(recentProcessingDocumentSnapshots.size() - 1);
+ }
+ }
+ }
+
+ private static final class ProcessingDocumentSnapshot {
+ final Node document;
+ final ResolvedSnapshot snapshot;
+
+ ProcessingDocumentSnapshot(Node document, ResolvedSnapshot snapshot) {
+ this.document = document;
+ this.snapshot = snapshot;
+ }
+ }
+
private void refreshDocumentProcessorConformanceEngine() {
if (documentProcessor != null) {
documentProcessor = new DocumentProcessor(documentProcessor.getContractRegistry(),
documentProcessor.getContractTypeResolver(),
- conformanceEngine(),
+ processorConformanceEngine(),
processingSnapshotManager(),
new ContractMatchingService(this),
documentProcessor.processingMetricsSink());
@@ -618,9 +950,9 @@ public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) {
private ResolvedSnapshot resolveProcessingSnapshot(Node node) {
Node preprocessed = preprocess(node.clone());
Node resolved = new Merger(mergingProcessor, processorSnapshotNodeProvider(), resolvedReferenceCache)
- .resolve(preprocessed.clone(), NO_LIMITS);
- Node canonical = reverse(resolved.clone());
- FrozenNode canonicalRoot = FrozenNode.fromNode(canonical);
+ .resolve(preprocessed.clone());
+ Node canonical = new MergeReverser().reverseToCanonicalOverlay(resolved.clone());
+ FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(canonical);
return cacheSnapshot(new ResolvedSnapshot(canonicalRoot, resolvedReferenceCache.freezeResolved(resolved), canonicalRoot.blueId()));
}
@@ -660,10 +992,54 @@ private ResolvedSnapshot snapshotFromCanonical(FrozenNode canonicalRoot, NodePro
}
Node canonical = canonicalRoot.toNode();
Node resolved = new Merger(mergingProcessor, snapshotNodeProvider, resolvedReferenceCache)
- .resolve(canonical.clone(), NO_LIMITS);
+ .resolve(canonical.clone());
return cacheSnapshot(new ResolvedSnapshot(canonicalRoot, resolvedReferenceCache.freezeResolved(resolved), canonicalRoot.blueId()));
}
+ private Set processorContractPaths(Node root) {
+ Set paths = new LinkedHashSet<>();
+ collectProcessorContractPaths(root, new ArrayList<>(), paths);
+ return paths;
+ }
+
+ private void collectProcessorContractPaths(Node node, List path, Set paths) {
+ if (node == null) {
+ return;
+ }
+ if (node.getContracts() != null) {
+ List contractsPath = new ArrayList<>(path);
+ contractsPath.add("contracts");
+ paths.add(JsonPointer.toPointer(contractsPath));
+ collectProcessorContractPaths(node.getContracts(), contractsPath, paths);
+ }
+ if (node.getProperties() != null) {
+ for (Map.Entry entry : node.getProperties().entrySet()) {
+ path.add(entry.getKey());
+ collectProcessorContractPaths(entry.getValue(), path, paths);
+ path.remove(path.size() - 1);
+ }
+ }
+ if (node.getItems() != null) {
+ for (int i = 0; i < node.getItems().size(); i++) {
+ path.add(String.valueOf(i));
+ collectProcessorContractPaths(node.getItems().get(i), path, paths);
+ path.remove(path.size() - 1);
+ }
+ }
+ }
+
+ private void restorePreservedPaths(Node resolved, Node source, Set paths) {
+ if (paths == null || paths.isEmpty()) {
+ return;
+ }
+ for (String path : paths) {
+ Node preserved = NodePathEditor.getOrNull(source, path);
+ if (preserved != null) {
+ NodePathEditor.put(resolved, path, preserved.clone());
+ }
+ }
+ }
+
private boolean canMinimizePatchedOverride(JsonPatch patch) {
if (patch == null || patch.getOp() == JsonPatch.Op.REMOVE) {
return false;
@@ -693,18 +1069,40 @@ private Set canonicalPreservedPaths(Collection preservedPaths) {
}
private NodeProvider processorSnapshotNodeProvider() {
- Set processorTypeBlueIds = new HashSet<>(PROCESSOR_MANAGED_TYPE_BLUE_IDS);
- if (documentProcessor != null) {
- processorTypeBlueIds.addAll(documentProcessor.getContractRegistry().processors().keySet());
- }
+ return new SequentialNodeProvider(
+ BootstrapProvider.INSTANCE,
+ BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(),
+ registeredExtensionTypeProvider(),
+ blueId -> BlueIds.isPotentialBlueId(blueId)
+ ? nodeProvider.fetchByBlueId(blueId)
+ : null);
+ }
+
+ private NodeProvider registeredExtensionTypeProvider() {
return blueId -> {
- if (processorTypeBlueIds.contains(blueId) || !BlueIds.isPotentialBlueId(blueId)) {
- return Collections.singletonList(new Node().name(blueId));
+ if (!BlueIds.isPotentialBlueId(blueId)
+ || BlueRuntimeTypeRegistry.getDefault().isProcessorManagedTypeBlueId(blueId)) {
+ return null;
}
- return nodeProvider.fetchByBlueId(blueId);
+ Node typeNode = externalContractTypeNodes.get(blueId);
+ return typeNode != null ? Collections.singletonList(typeNode.clone()) : null;
};
}
+ private void registerExternalTypeNode(String blueId, Node canonicalTypeNode) {
+ if (blueId == null || blueId.isEmpty()) {
+ throw new IllegalArgumentException("blueId must not be empty");
+ }
+ Objects.requireNonNull(canonicalTypeNode, "canonicalTypeNode");
+ Node canonical = canonicalTypeNode.clone();
+ String calculated = BlueIdCalculator.calculateBlueId(canonical);
+ if (!blueId.equals(calculated)) {
+ throw new IllegalArgumentException("External contract type node hashes to " + calculated
+ + ", not declared BlueId " + blueId);
+ }
+ externalContractTypeNodes.put(blueId, canonical);
+ }
+
private ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) {
resolvedReferenceCache.putIfAbsent(snapshot.blueId(), snapshot.frozenResolvedRoot());
resolvedReferenceCache.indexResolved(snapshot.frozenResolvedRoot());
diff --git a/src/main/java/blue/language/BlueConformanceFailure.java b/src/main/java/blue/language/BlueConformanceFailure.java
new file mode 100644
index 0000000..d115afb
--- /dev/null
+++ b/src/main/java/blue/language/BlueConformanceFailure.java
@@ -0,0 +1,57 @@
+package blue.language;
+
+public final class BlueConformanceFailure {
+
+ private final String fixtureId;
+ private final BlueFixtureCategory category;
+ private final String operation;
+ private final String exceptionClass;
+ private final String message;
+ private final BlueLanguageErrorCategory errorCategory;
+
+ public BlueConformanceFailure(String fixtureId,
+ BlueFixtureCategory category,
+ String operation,
+ String exceptionClass,
+ String message) {
+ this(fixtureId, category, operation, exceptionClass, message, null);
+ }
+
+ public BlueConformanceFailure(String fixtureId,
+ BlueFixtureCategory category,
+ String operation,
+ String exceptionClass,
+ String message,
+ BlueLanguageErrorCategory errorCategory) {
+ this.fixtureId = fixtureId;
+ this.category = category;
+ this.operation = operation;
+ this.exceptionClass = exceptionClass;
+ this.message = message;
+ this.errorCategory = errorCategory;
+ }
+
+ public String getFixtureId() {
+ return fixtureId;
+ }
+
+ public BlueFixtureCategory getCategory() {
+ return category;
+ }
+
+ public String getOperation() {
+ return operation;
+ }
+
+ public String getExceptionClass() {
+ return exceptionClass;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public BlueLanguageErrorCategory getErrorCategory() {
+ return errorCategory;
+ }
+}
diff --git a/src/main/java/blue/language/BlueConformanceReport.java b/src/main/java/blue/language/BlueConformanceReport.java
new file mode 100644
index 0000000..1f30777
--- /dev/null
+++ b/src/main/java/blue/language/BlueConformanceReport.java
@@ -0,0 +1,373 @@
+package blue.language;
+
+import blue.language.utils.UncheckedObjectMapper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public final class BlueConformanceReport {
+
+ public static final String FIXTURE_MANIFEST_RESOURCE = "blue-language-1.0/fixtures/manifest.yaml";
+ private static final Set REQUIRED_FIXTURE_IDS = requiredFixtureIds();
+
+ private final String specVersion;
+ private final Map coreRegistryBlueIds;
+ private final String fixturePackageIdentity;
+ private final List fixtureIds;
+ private final List passedFixtureIds;
+ private final List failedFixtureIds;
+ private final List failures;
+ private final Map fixtureCategories;
+
+ public BlueConformanceReport(String specVersion,
+ Map coreRegistryBlueIds,
+ String fixturePackageIdentity,
+ List passedFixtureIds) {
+ this(specVersion, coreRegistryBlueIds, fixturePackageIdentity, Collections.emptyList(), passedFixtureIds, Collections.emptyList(), Collections.emptyMap());
+ }
+
+ public BlueConformanceReport(String specVersion,
+ Map coreRegistryBlueIds,
+ String fixturePackageIdentity,
+ List fixtureIds,
+ List passedFixtureIds,
+ List failedFixtureIds,
+ Map fixtureCategories) {
+ this(specVersion, coreRegistryBlueIds, fixturePackageIdentity, fixtureIds, passedFixtureIds, failedFixtureIds, fixtureCategories, Collections.emptyList());
+ }
+
+ public BlueConformanceReport(String specVersion,
+ Map coreRegistryBlueIds,
+ String fixturePackageIdentity,
+ List fixtureIds,
+ List passedFixtureIds,
+ List failedFixtureIds,
+ Map fixtureCategories,
+ List failures) {
+ this.specVersion = specVersion;
+ this.coreRegistryBlueIds = Collections.unmodifiableMap(new LinkedHashMap<>(coreRegistryBlueIds));
+ this.fixturePackageIdentity = fixturePackageIdentity;
+ this.fixtureIds = Collections.unmodifiableList(new ArrayList<>(fixtureIds));
+ this.passedFixtureIds = Collections.unmodifiableList(new ArrayList<>(passedFixtureIds));
+ List effectiveFailedFixtureIds = new ArrayList<>(failedFixtureIds);
+ if (!failures.isEmpty()) {
+ effectiveFailedFixtureIds.clear();
+ for (BlueConformanceFailure failure : failures) {
+ effectiveFailedFixtureIds.add(failure.getFixtureId());
+ }
+ }
+ this.failedFixtureIds = Collections.unmodifiableList(effectiveFailedFixtureIds);
+ this.failures = Collections.unmodifiableList(new ArrayList<>(failures));
+ this.fixtureCategories = Collections.unmodifiableMap(new LinkedHashMap<>(fixtureCategories));
+ }
+
+ public String getSpecVersion() {
+ return specVersion;
+ }
+
+ public Map getCoreRegistryBlueIds() {
+ return coreRegistryBlueIds;
+ }
+
+ public String getFixturePackageIdentity() {
+ return fixturePackageIdentity;
+ }
+
+ public List getFixtureIds() {
+ return fixtureIds;
+ }
+
+ public List getPassedFixtureIds() {
+ return passedFixtureIds;
+ }
+
+ public List getFailedFixtureIds() {
+ return failedFixtureIds;
+ }
+
+ public List getFailures() {
+ return failures;
+ }
+
+ public Map getFixtureCategories() {
+ return fixtureCategories;
+ }
+
+ public boolean isReleaseGradeFixtureIdentity() {
+ return isReleaseGradeFixtureIdentity(fixturePackageIdentity);
+ }
+
+ public boolean hasRequiredFixtureCoverage() {
+ return new HashSet<>(fixtureIds).containsAll(REQUIRED_FIXTURE_IDS);
+ }
+
+ public boolean hasExactRequiredFixtureSet() {
+ return new LinkedHashSet<>(fixtureIds).equals(REQUIRED_FIXTURE_IDS);
+ }
+
+ public static Set requiredFixtureIdsForBlueLanguage10() {
+ return Collections.unmodifiableSet(REQUIRED_FIXTURE_IDS);
+ }
+
+ public static String loadFixturePackageIdentity(String fallback) {
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null) {
+ return fallback;
+ }
+ Object identity = manifest.get("fixturePackageIdentity");
+ return identity == null || identity.toString().trim().isEmpty()
+ ? fallback
+ : identity.toString();
+ }
+
+ public static List loadFixtureIds() {
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null) {
+ return Collections.emptyList();
+ }
+ Object fixtures = manifest.get("fixtures");
+ if (!(fixtures instanceof List)) {
+ return Collections.emptyList();
+ }
+ List> fixtureList = (List>) fixtures;
+ List ids = new ArrayList<>();
+ for (Object fixture : fixtureList) {
+ if (fixture instanceof Map) {
+ Object id = ((Map, ?>) fixture).get("id");
+ if (id != null) {
+ ids.add(id.toString());
+ }
+ }
+ }
+ return ids;
+ }
+
+ public static Map loadFixtureCategories() {
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null) {
+ return Collections.emptyMap();
+ }
+ Object fixtures = manifest.get("fixtures");
+ if (!(fixtures instanceof List)) {
+ return Collections.emptyMap();
+ }
+ Map categories = new LinkedHashMap<>();
+ for (Object fixture : (List>) fixtures) {
+ if (fixture instanceof Map) {
+ Map, ?> fixtureMap = (Map, ?>) fixture;
+ Object id = fixtureMap.get("id");
+ Object category = fixtureMap.get("category");
+ if (id != null && category != null) {
+ categories.put(id.toString(), BlueFixtureCategory.fromLabel(category.toString()));
+ }
+ }
+ }
+ return categories;
+ }
+
+ public static String computeFixturePackageIdentity() {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update("manifest.yaml\n".getBytes(StandardCharsets.UTF_8));
+ digest.update(normalizeManifestForIdentity(readFixtureResource(FIXTURE_MANIFEST_RESOURCE)));
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null) {
+ throw new IllegalStateException("Blue Language fixture manifest not found");
+ }
+ Object fixtures = manifest.get("fixtures");
+ if (!(fixtures instanceof List)) {
+ throw new IllegalStateException("Blue Language fixture manifest has no fixture list");
+ }
+ for (Object fixture : (List>) fixtures) {
+ if (!(fixture instanceof Map)) {
+ throw new IllegalStateException("Blue Language fixture manifest contains a non-map fixture entry");
+ }
+ Object path = ((Map, ?>) fixture).get("path");
+ if (path == null || path.toString().trim().isEmpty()) {
+ throw new IllegalStateException("Blue Language fixture manifest entry is missing path");
+ }
+ String fixturePath = path.toString();
+ digest.update(("\n--- " + fixturePath + "\n").getBytes(StandardCharsets.UTF_8));
+ digest.update(normalizeLineEndings(readFixtureResource("blue-language-1.0/fixtures/" + fixturePath)));
+ }
+ return "sha256:" + toHex(digest.digest());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 digest is unavailable", e);
+ }
+ }
+
+ public static boolean fixturePackageIdentityMatchesFixtureFiles() {
+ String identity = loadFixturePackageIdentity(null);
+ return identity != null && identity.equals(computeFixturePackageIdentity());
+ }
+
+ public static boolean isReleaseGradeFixtureIdentity(String identity) {
+ if (identity == null || identity.trim().isEmpty()) {
+ return false;
+ }
+ String trimmed = identity.trim();
+ if (trimmed.contains("local-dev")
+ || trimmed.contains("pending")
+ || trimmed.contains("unavailable")) {
+ return false;
+ }
+ if (trimmed.startsWith("sha256:")) {
+ return trimmed.substring("sha256:".length()).matches("[0-9a-f]{64}");
+ }
+ return trimmed.startsWith("blueId:") && trimmed.length() > "blueId:".length();
+ }
+
+ private static Map, ?> loadFixtureManifest() {
+ try (InputStream inputStream = BlueConformanceReport.class.getClassLoader()
+ .getResourceAsStream(FIXTURE_MANIFEST_RESOURCE)) {
+ if (inputStream == null) {
+ return null;
+ }
+ return UncheckedObjectMapper.YAML_MAPPER.readValue(inputStream, Map.class);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private static byte[] readFixtureResource(String resource) {
+ try (InputStream inputStream = BlueConformanceReport.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IllegalStateException("Missing Blue Language fixture resource: " + resource);
+ }
+ return readAll(inputStream);
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to read Blue Language fixture resource: " + resource, e);
+ }
+ }
+
+ private static byte[] readAll(InputStream inputStream) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = inputStream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] normalizeManifestForIdentity(byte[] bytes) {
+ String normalized = new String(normalizeLineEndings(bytes), StandardCharsets.UTF_8)
+ .replaceFirst("(?m)^fixturePackageIdentity:.*$", "fixturePackageIdentity: \"\"");
+ return normalized.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static byte[] normalizeLineEndings(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8)
+ .replace("\r\n", "\n")
+ .replace("\r", "\n")
+ .getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static String toHex(byte[] bytes) {
+ StringBuilder result = new StringBuilder(bytes.length * 2);
+ for (byte value : bytes) {
+ result.append(String.format("%02x", value & 0xff));
+ }
+ return result.toString();
+ }
+
+ private static Set requiredFixtureIds() {
+ return set(
+ "L_no_profile_era_language_conformance_terms",
+ "coreRegistryTextNodeHashesToPublishedBlueId",
+ "coreRegistryIntegerNodeHashesToPublishedBlueId",
+ "coreRegistryDoubleNodeHashesToPublishedBlueId",
+ "coreRegistryBooleanNodeHashesToPublishedBlueId",
+ "coreRegistryDictionaryNodeHashesToPublishedBlueId",
+ "coreRegistryListNodeHashesToPublishedBlueId",
+ "changingCoreTypeDescriptionChangesBlueId",
+ "B_scalar_sugar_equivalence",
+ "B_list_sugar_equivalence",
+ "B_root_scalar",
+ "B_root_list",
+ "B_root_empty_object",
+ "B_root_pure_reference",
+ "B_root_null_rejected",
+ "B_plain_blueid_validation",
+ "B_empty_list",
+ "B_object_field_null_removal",
+ "B_empty_placeholder",
+ "B_null_list_element_rejected",
+ "B_empty_object_list_element_rejected",
+ "B_malformed_empty_rejected",
+ "B_large_integer_quoted_explicit_integer",
+ "B_unquoted_large_integer_rejected",
+ "B_integer_1_vs_double_1_0",
+ "B_double_1e0",
+ "B_invalid_this_placeholder_rejected",
+ "B_type_alias_rejected_in_direct_blueid_input",
+ "B_previous_invalid_blueid_rejected",
+ "B_pos_rejected",
+ "B_replace_rejected",
+ "R_blue_imports_type_itemType_keyType_valueType",
+ "R_source_null_list_to_empty",
+ "R_source_empty_object_list_to_empty",
+ "R_blue_imports",
+ "R_schema_value_shapes",
+ "R_schema_large_integer_minimum_with_type_alias",
+ "R_schema_integer_multiple_of_lcm_merge",
+ "R_enum_integer_vs_double",
+ "R_canonical_overlay_no_previous_no_pos",
+ "R_inherited_append_only_policy",
+ "R_inherited_item_type",
+ "R_inherited_keyType_valueType",
+ "R_provider_reference_canonicalizes_back",
+ "R_type_aliases_removed_from_canonical_overlay",
+ "R_contracts_merge_as_content",
+ "R_top_level_type_name_description_not_inherited",
+ "R_type_derived_field_removed",
+ "R_instance_field_kept",
+ "R_provider_reference_with_overlay_keeps_overlay",
+ "R_contracts_canonicalization_deterministic",
+ "R_child_field_labels_materialize_until_overridden",
+ "R_canonicalization_deterministic_for_same_resolved_view",
+ "F_provider_wrong_blueid_rejected",
+ "F_provider_missing_content_fails",
+ "F_expand_preserves_node_blueid",
+ "F_expand_nested_reference_preserves_node_blueid",
+ "F_expand_wrong_nested_provider_content_fails",
+ "F_expand_missing_nested_content_fails",
+ "F_collapse_preserves_node_blueid",
+ "F_collapse_nested_subtree_preserves_node_blueid",
+ "F_collapse_does_not_produce_mixed_blueid",
+ "C_circular_reference_set_ids",
+ "C_this_placeholder_rejected_outside_cyclic_api",
+ "C_zero_blueid_rejected_in_final_input",
+ "C_three_document_cycle_stable_order",
+ "C_duplicate_preliminary_ids_deterministic_or_rejected",
+ "B_double_negative_zero",
+ "B_double_overflow_rejected",
+ "B_payload_only_scalar_typed_identity",
+ "R_source_recursive_empty_object_list_to_empty",
+ "R_core_type_compatibility_nominal_by_blueid",
+ "R_view_path_root_is_empty_string",
+ "R_schema_enum_order_and_duplicates_canonical",
+ "R_schema_double_multiple_of_exact",
+ "R_schema_double_multiple_of_rejects_decimal_approximation",
+ "R_schema_wrong_kind_keywords_rejected");
+ }
+
+ private static Set set(String... values) {
+ return Arrays.stream(values).collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+}
diff --git a/src/main/java/blue/language/BlueConformanceSuiteRunner.java b/src/main/java/blue/language/BlueConformanceSuiteRunner.java
new file mode 100644
index 0000000..a1263e2
--- /dev/null
+++ b/src/main/java/blue/language/BlueConformanceSuiteRunner.java
@@ -0,0 +1,683 @@
+package blue.language;
+
+import blue.language.model.Node;
+import blue.language.model.Schema;
+import blue.language.registry.BlueCoreTypeRegistry;
+import blue.language.snapshot.FrozenNode;
+import blue.language.utils.BlueIdCalculator;
+import blue.language.utils.CircularBlueIdCalculator;
+import blue.language.utils.Nodes;
+import blue.language.utils.Properties;
+import blue.language.utils.UncheckedObjectMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class BlueConformanceSuiteRunner {
+
+ private static final String FIXTURE_ROOT = "blue-language-1.0/fixtures/";
+ private static final Set OPERATIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+ "parseSource",
+ "parseBlueIdInput",
+ "calculateBlueId",
+ "calculateCircularSetBlueIds",
+ "preprocess",
+ "resolve",
+ "canonicalize",
+ "calculateContentBlueId",
+ "calculateSemanticBlueId",
+ "expand",
+ "collapse",
+ "assertSameNodeBlueId",
+ "assertViewPath",
+ "registryNodeHashesToPublishedBlueId",
+ "changingRegistryDescriptionChangesBlueId",
+ "lintPublishableDocumentation"
+ )));
+
+ private BlueConformanceSuiteRunner() {
+ }
+
+ public static BlueConformanceReport run(Blue blue) {
+ BlueConformanceReport metadata = blue.conformanceReport();
+ List passed = new ArrayList<>();
+ List failures = new ArrayList<>();
+ for (FixtureEntry fixture : fixtureEntries()) {
+ try {
+ runFixture(fixture);
+ passed.add(fixture.id);
+ } catch (RuntimeException | AssertionError e) {
+ failures.add(failure(fixture, e));
+ }
+ }
+ return new BlueConformanceReport(
+ metadata.getSpecVersion(),
+ metadata.getCoreRegistryBlueIds(),
+ metadata.getFixturePackageIdentity(),
+ metadata.getFixtureIds(),
+ passed,
+ Collections.emptyList(),
+ metadata.getFixtureCategories(),
+ failures);
+ }
+
+ public static Set knownOperations() {
+ return OPERATIONS;
+ }
+
+ public static void validateFixtureMetadataForTest(JsonNode spec) {
+ validateFixtureMetadata(spec);
+ }
+
+ private static List fixtureEntries() {
+ JsonNode manifest = readResource(FIXTURE_ROOT + "manifest.yaml");
+ JsonNode fixtures = requireNonNull(manifest, "fixtures");
+ if (!fixtures.isArray()) {
+ throw new IllegalArgumentException("Fixture manifest field \"fixtures\" must be a list.");
+ }
+ List entries = new ArrayList<>();
+ for (JsonNode entry : fixtures) {
+ String id = requireNonNull(entry, "id").asText();
+ String category = requireNonNull(entry, "category").asText();
+ BlueFixtureCategory.fromLabel(category);
+ String path = requireNonNull(entry, "path").asText();
+ entries.add(new FixtureEntry(id, category, path));
+ }
+ return entries;
+ }
+
+ private static void runFixture(FixtureEntry fixture) {
+ JsonNode spec = readResource(FIXTURE_ROOT + fixture.path);
+ validateFixtureMatchesManifest(fixture, spec);
+ String operation = text(spec, "operation", "calculateBlueId");
+ boolean expectError = spec.path("expectError").asBoolean(false);
+ if (expectError) {
+ try {
+ runOperation(spec, operation);
+ } catch (RuntimeException expected) {
+ assertExpectedErrorCategory(spec, expected);
+ return;
+ }
+ throw new AssertionError("Fixture expected an error but operation succeeded: " + fixture.id);
+ }
+
+ Object actual = runOperation(spec, operation);
+ if ("calculateBlueId".equals(operation)
+ || "assertSameNodeBlueId".equals(operation)) {
+ assertExpectedText(spec, "expectedNodeBlueId", (String) actual);
+ if (!"assertSameNodeBlueId".equals(operation)) {
+ assertEquivalents((String) actual, spec.get("alsoEquivalentTo"));
+ assertDifferent((String) actual, spec.get("alsoDifferentFrom"));
+ }
+ } else if ("calculateCircularSetBlueIds".equals(operation)) {
+ assertExpectedTextList(spec, "expectedBlueIds", (List) actual);
+ } else if ("calculateContentBlueId".equals(operation) || "calculateSemanticBlueId".equals(operation)) {
+ assertExpectedText(spec, "expectedContentBlueId", (String) actual);
+ } else if ("parseSource".equals(operation) || "parseBlueIdInput".equals(operation)) {
+ assertExpectedNode(spec, "expectedParsed", (Node) actual);
+ } else if ("preprocess".equals(operation)) {
+ assertExpectedNode(spec, "expectedPreprocessed", (Node) actual);
+ } else if ("canonicalize".equals(operation)) {
+ assertExpectedNode(spec, "expectedCanonicalOverlay", (Node) actual);
+ assertCanonicalOverlayIsValidBlueIdInput((Node) actual);
+ } else if ("resolve".equals(operation)) {
+ assertExpectedNode(spec, "expectedResolved", (Node) actual);
+ } else if ("expand".equals(operation)) {
+ assertExpectedNode(spec, "expectedExpanded", (Node) actual);
+ assertExpectedNodeBlueIdIfPresent(spec, (Node) actual, requirePresent(spec, "source"));
+ } else if ("collapse".equals(operation)) {
+ assertExpectedNode(spec, "expectedCollapsed", (Node) actual);
+ assertExpectedNodeBlueIdIfPresent(spec, (Node) actual, requirePresent(spec, "source"));
+ } else if ("assertViewPath".equals(operation)) {
+ // Operation-specific assertions are performed while running the fixture.
+ } else if ("registryNodeHashesToPublishedBlueId".equals(operation)
+ || "changingRegistryDescriptionChangesBlueId".equals(operation)
+ || "lintPublishableDocumentation".equals(operation)) {
+ // Operation-specific assertions are performed while running the fixture.
+ }
+ }
+
+ private static Object runOperation(JsonNode spec, String operation) {
+ Blue blue = new Blue(provider(spec.get("provider")));
+ if ("parseSource".equals(operation)) {
+ return blue.parseSourceYaml(UncheckedObjectMapper.YAML_MAPPER.writeValueAsString(requirePresent(spec, "source")));
+ }
+ if ("parseBlueIdInput".equals(operation)) {
+ return blue.parseBlueIdInputYaml(UncheckedObjectMapper.YAML_MAPPER.writeValueAsString(requirePresent(spec, "input")));
+ }
+ if ("calculateBlueId".equals(operation)) {
+ Node input = readNode(requirePresent(spec, "input"));
+ String blueId = BlueIdCalculator.calculateBlueId(input);
+ assertEquals(blueId, FrozenNode.fromNode(input).blueId());
+ return blueId;
+ }
+ if ("calculateCircularSetBlueIds".equals(operation)) {
+ Node documents = readNode(requirePresent(spec, "documents"));
+ if (documents.getItems() == null) {
+ throw new IllegalArgumentException("calculateCircularSetBlueIds fixtures require a documents list.");
+ }
+ return CircularBlueIdCalculator.calculateCircularSetBlueIds(documents.getItems());
+ }
+ if ("preprocess".equals(operation)) {
+ return blue.preprocess(readNode(requirePresent(spec, "source")));
+ }
+ if ("resolve".equals(operation)) {
+ return blue.resolve(readNode(requirePresent(spec, "source")));
+ }
+ if ("canonicalize".equals(operation)) {
+ return blue.canonicalize(readNode(requirePresent(spec, "source")));
+ }
+ if ("calculateContentBlueId".equals(operation) || "calculateSemanticBlueId".equals(operation)) {
+ return blue.calculateSemanticBlueId(readNode(requirePresent(spec, "source")));
+ }
+ if ("expand".equals(operation)) {
+ return blue.expand(readNode(requirePresent(spec, "source")));
+ }
+ if ("collapse".equals(operation)) {
+ return blue.collapse(readNode(requirePresent(spec, "source")));
+ }
+ if ("assertSameNodeBlueId".equals(operation)) {
+ String left = BlueIdCalculator.calculateBlueId(readNode(requirePresent(spec, "left")));
+ String right = BlueIdCalculator.calculateBlueId(readNode(requirePresent(spec, "right")));
+ assertEquals(left, right);
+ return left;
+ }
+ if ("assertViewPath".equals(operation)) {
+ runAssertViewPath(spec);
+ return null;
+ }
+ if ("registryNodeHashesToPublishedBlueId".equals(operation)) {
+ runRegistryNodeHashesToPublishedBlueId(spec);
+ return null;
+ }
+ if ("changingRegistryDescriptionChangesBlueId".equals(operation)) {
+ runChangingRegistryDescriptionChangesBlueId(spec);
+ return null;
+ }
+ if ("lintPublishableDocumentation".equals(operation)) {
+ runLintPublishableDocumentation(spec);
+ return null;
+ }
+ throw new IllegalArgumentException("Unsupported fixture operation: " + operation);
+ }
+
+ private static void runAssertViewPath(JsonNode spec) {
+ Node document = readNode(requirePresent(spec, "document"));
+ JsonNode assertions = requireNonNull(spec, "assertions");
+ if (!assertions.isArray() || assertions.size() == 0) {
+ throw new IllegalArgumentException("assertViewPath requires at least one assertion.");
+ }
+ for (JsonNode assertion : assertions) {
+ String path = requireNonNull(assertion, "path").asText();
+ Node selected = BlueViewPath.select(document, path);
+ if (assertion.path("expectedRoot").asBoolean(false)) {
+ assertEquals(BlueIdCalculator.calculateBlueId(document), BlueIdCalculator.calculateBlueId(selected));
+ }
+ if (assertion.has("expectedNode")) {
+ assertNodeEquals(readNode(requireNonNull(assertion, "expectedNode")), selected);
+ }
+ }
+ }
+
+ private static void runRegistryNodeHashesToPublishedBlueId(JsonNode spec) {
+ requireCoreRegistryKind(spec);
+ String registryKey = requireNonNull(spec, "registryKey").asText();
+ String expected = requireNonNull(spec, "expectedPublishedBlueId").asText();
+ BlueCoreTypeRegistry registry = BlueCoreTypeRegistry.INSTANCE;
+ String calculated = BlueIdCalculator.calculateBlueId(registry.node(registryKey));
+ assertEquals(expected, calculated);
+ assertEquals(expected, registry.blueId(registryKey));
+ assertEquals(expected, Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP.get(registryKey));
+ }
+
+ private static void runChangingRegistryDescriptionChangesBlueId(JsonNode spec) {
+ requireCoreRegistryKind(spec);
+ String registryKey = requireNonNull(spec, "registryKey").asText();
+ Node original = BlueCoreTypeRegistry.INSTANCE.node(registryKey);
+ Node mutated = original.clone();
+ JsonNode mutation = requireNonNull(spec, "mutation");
+ String field = requireNonNull(mutation, "field").asText();
+ if (!"description".equals(field)) {
+ throw new IllegalArgumentException("Unsupported registry mutation field: " + field);
+ }
+ mutated.description((mutated.getDescription() == null ? "" : mutated.getDescription())
+ + requireNonNull(mutation, "append").asText());
+ boolean changed = !BlueIdCalculator.calculateBlueId(original).equals(BlueIdCalculator.calculateBlueId(mutated));
+ assertEquals(requireNonNull(spec, "expectBlueIdChanged").asBoolean(), changed);
+ }
+
+ private static void runLintPublishableDocumentation(JsonNode spec) {
+ JsonNode files = requireNonNull(spec, "publishableFiles");
+ if (!files.isArray() || files.size() == 0) {
+ throw new IllegalArgumentException("lintPublishableDocumentation requires publishableFiles.");
+ }
+ JsonNode requiredHeadings = spec.get("requiredHeadings");
+ JsonNode forbiddenJoinedTerms = spec.get("forbiddenJoinedTerms");
+ if ((requiredHeadings == null || !requiredHeadings.isArray() || requiredHeadings.size() == 0)
+ && (forbiddenJoinedTerms == null || !forbiddenJoinedTerms.isArray() || forbiddenJoinedTerms.size() == 0)) {
+ throw new IllegalArgumentException("lintPublishableDocumentation requires headings or forbidden terms.");
+ }
+ for (JsonNode file : files) {
+ String path = file.asText();
+ String content = readTextResource(path);
+ if (requiredHeadings != null) {
+ for (JsonNode heading : requiredHeadings) {
+ if (!content.contains(heading.asText())) {
+ throw new AssertionError("Missing required heading in " + path + ": " + heading.asText());
+ }
+ }
+ }
+ if (forbiddenJoinedTerms != null) {
+ for (JsonNode entry : forbiddenJoinedTerms) {
+ JsonNode tokens = requireNonNull(entry, "tokens");
+ String joiner = requireNonNull(entry, "joiner").asText();
+ List tokenValues = new ArrayList<>();
+ for (JsonNode token : tokens) {
+ tokenValues.add(token.asText());
+ }
+ String forbidden = String.join(joiner, tokenValues);
+ if (content.contains(forbidden)) {
+ throw new AssertionError("Forbidden term in " + path + ": " + forbidden);
+ }
+ }
+ }
+ }
+ }
+
+ private static void requireCoreRegistryKind(JsonNode spec) {
+ String registryKind = requireNonNull(spec, "registryKind").asText();
+ if (!"Blue Language core type registry".equals(registryKind)) {
+ throw new IllegalArgumentException("Unsupported registry kind: " + registryKind);
+ }
+ }
+
+ private static NodeProvider provider(JsonNode providerSpec) {
+ if (providerSpec == null || providerSpec.isNull()) {
+ return blueId -> null;
+ }
+ if (!providerSpec.isArray()) {
+ throw new IllegalArgumentException("Fixture provider must be a list.");
+ }
+ Map nodesByBlueId = new LinkedHashMap<>();
+ for (JsonNode entry : providerSpec) {
+ String requestedBlueId = text(entry, "requestedBlueId", text(entry, "blueId", null));
+ JsonNode nodeSpec = entry.has("returnedNode") ? entry.get("returnedNode") : entry.get("node");
+ if (requestedBlueId == null || nodeSpec == null || nodeSpec.isNull()) {
+ throw new IllegalArgumentException("Fixture provider entries require requestedBlueId and node/returnedNode.");
+ }
+ nodesByBlueId.put(requestedBlueId, readNode(nodeSpec));
+ }
+ return blueId -> {
+ Node node = nodesByBlueId.get(blueId);
+ return node == null ? null : Collections.singletonList(node.clone());
+ };
+ }
+
+ private static void validateFixtureMatchesManifest(FixtureEntry fixture, JsonNode spec) {
+ validateFixtureMetadata(spec);
+ assertEquals(fixture.id, requireNonNull(spec, "id").asText());
+ assertEquals(
+ BlueFixtureCategory.fromLabel(fixture.category),
+ BlueFixtureCategory.fromLabel(requireNonNull(spec, "category").asText()));
+ }
+
+ private static void validateFixtureMetadata(JsonNode spec) {
+ requireNonNull(spec, "id");
+ requireNonNull(spec, "category");
+ requireNonNull(spec, "operation");
+ if (spec.has("profile")) {
+ throw new IllegalArgumentException("Fixtures must use category, not profile.");
+ }
+ BlueFixtureCategory.fromLabel(requireNonNull(spec, "category").asText());
+ String operation = requireNonNull(spec, "operation").asText();
+ if (!OPERATIONS.contains(operation)) {
+ throw new IllegalArgumentException("Unsupported fixture operation: " + operation);
+ }
+ if (!spec.path("expectError").asBoolean(false)) {
+ requireExpectedOutput(spec, operation);
+ } else {
+ validateExpectedErrorCategoryFields(spec);
+ }
+ }
+
+ private static BlueConformanceFailure failure(FixtureEntry fixture, Throwable throwable) {
+ String operation = null;
+ try {
+ operation = text(readResource(FIXTURE_ROOT + fixture.path), "operation", null);
+ } catch (RuntimeException ignored) {
+ // The fixture may be unreadable; keep the manifest-level failure details.
+ }
+ return new BlueConformanceFailure(
+ fixture.id,
+ BlueFixtureCategory.fromLabel(fixture.category),
+ operation,
+ throwable.getClass().getName(),
+ throwable.getMessage(),
+ BlueLanguageErrorClassifier.classify(throwable));
+ }
+
+ private static void requireExpectedOutput(JsonNode spec, String operation) {
+ if ("calculateBlueId".equals(operation)
+ || "assertSameNodeBlueId".equals(operation)) {
+ requireNonNull(spec, "expectedNodeBlueId");
+ return;
+ }
+ if ("calculateCircularSetBlueIds".equals(operation)) {
+ requireNonNull(spec, "expectedBlueIds");
+ return;
+ }
+ if ("calculateContentBlueId".equals(operation) || "calculateSemanticBlueId".equals(operation)) {
+ requireNonNull(spec, "expectedContentBlueId");
+ return;
+ }
+ if ("parseSource".equals(operation) || "parseBlueIdInput".equals(operation)) {
+ requireNonNull(spec, "expectedParsed");
+ return;
+ }
+ if ("preprocess".equals(operation)) {
+ requireNonNull(spec, "expectedPreprocessed");
+ return;
+ }
+ if ("canonicalize".equals(operation)) {
+ requireNonNull(spec, "expectedCanonicalOverlay");
+ return;
+ }
+ if ("resolve".equals(operation)) {
+ requireNonNull(spec, "expectedResolved");
+ return;
+ }
+ if ("expand".equals(operation)) {
+ requireNonNull(spec, "expectedExpanded");
+ return;
+ }
+ if ("collapse".equals(operation)) {
+ requireNonNull(spec, "expectedCollapsed");
+ return;
+ }
+ if ("assertViewPath".equals(operation)) {
+ requireNonNull(spec, "assertions");
+ return;
+ }
+ if ("registryNodeHashesToPublishedBlueId".equals(operation)) {
+ requireNonNull(spec, "expectedPublishedBlueId");
+ return;
+ }
+ if ("changingRegistryDescriptionChangesBlueId".equals(operation)) {
+ requireNonNull(spec, "expectBlueIdChanged");
+ return;
+ }
+ if ("lintPublishableDocumentation".equals(operation)) {
+ requireNonNull(spec, "publishableFiles");
+ if (!spec.has("requiredHeadings") && !spec.has("forbiddenJoinedTerms")) {
+ throw new IllegalArgumentException("lintPublishableDocumentation must assert headings or forbidden terms.");
+ }
+ return;
+ }
+ throw new IllegalArgumentException("Unsupported fixture operation: " + operation);
+ }
+
+ private static void validateExpectedErrorCategoryFields(JsonNode spec) {
+ if (spec.has("expectedErrorCategory")) {
+ BlueLanguageErrorCategory.valueOf(requireNonNull(spec, "expectedErrorCategory").asText());
+ }
+ if (spec.has("expectedErrorCategories")) {
+ JsonNode categories = requireNonNull(spec, "expectedErrorCategories");
+ if (!categories.isArray() || categories.size() == 0) {
+ throw new IllegalArgumentException("expectedErrorCategories must be a non-empty list.");
+ }
+ for (JsonNode category : categories) {
+ BlueLanguageErrorCategory.valueOf(category.asText());
+ }
+ }
+ }
+
+ private static void assertExpectedErrorCategory(JsonNode spec, Throwable throwable) {
+ JsonNode expected = spec.get("expectedErrorCategory");
+ JsonNode allowed = spec.get("expectedErrorCategories");
+ if ((expected == null || expected.isNull()) && (allowed == null || allowed.isNull())) {
+ return;
+ }
+ BlueLanguageErrorCategory actual = BlueLanguageErrorClassifier.classify(throwable);
+ if (expected != null && !expected.isNull()) {
+ assertEquals(BlueLanguageErrorCategory.valueOf(expected.asText()), actual);
+ }
+ if (allowed != null && !allowed.isNull()) {
+ for (JsonNode category : allowed) {
+ if (BlueLanguageErrorCategory.valueOf(category.asText()) == actual) {
+ return;
+ }
+ }
+ throw new AssertionError("Expected error category in " + allowed + " but was " + actual
+ + " for error: " + throwable.getMessage());
+ }
+ }
+
+ private static void assertExpectedText(JsonNode spec, String field, String actual) {
+ assertEquals(requireNonNull(spec, field).asText(), actual);
+ }
+
+ private static void assertExpectedTextList(JsonNode spec, String field, List actual) {
+ JsonNode expected = requireNonNull(spec, field);
+ if (!expected.isArray()) {
+ throw new AssertionError("Expected fixture field \"" + field + "\" to be a list.");
+ }
+ List expectedValues = new ArrayList<>();
+ for (JsonNode value : expected) {
+ expectedValues.add(value.asText());
+ }
+ assertEquals(expectedValues, actual);
+ }
+
+ private static void assertExpectedNode(JsonNode spec, String field, Node actual) {
+ assertNodeEquals(readNode(requireNonNull(spec, field)), actual);
+ }
+
+ private static void assertNodeEquals(Node expectedNode, Node actual) {
+ JsonNode expected = UncheckedObjectMapper.YAML_MAPPER.readTree(
+ UncheckedObjectMapper.YAML_MAPPER.writeValueAsString(expectedNode));
+ JsonNode actualTree = UncheckedObjectMapper.YAML_MAPPER.readTree(
+ UncheckedObjectMapper.YAML_MAPPER.writeValueAsString(actual));
+ assertEquals(expected, actualTree);
+ }
+
+ private static void assertExpectedNodeBlueIdIfPresent(JsonNode spec, Node actual, JsonNode sourceSpec) {
+ JsonNode expected = spec.get("expectedNodeBlueId");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ String expectedBlueId = expected.asText();
+ assertEquals(expectedBlueId, BlueIdCalculator.calculateBlueId(actual));
+ assertEquals(expectedBlueId, BlueIdCalculator.calculateBlueId(readNode(sourceSpec)));
+ }
+
+ private static void assertEquivalents(String actualBlueId, JsonNode equivalents) {
+ if (equivalents == null || equivalents.isNull()) {
+ return;
+ }
+ if (equivalents.isArray()) {
+ for (JsonNode equivalent : equivalents) {
+ assertEquals(actualBlueId, BlueIdCalculator.calculateBlueId(readNode(equivalent)));
+ }
+ } else {
+ assertEquals(actualBlueId, BlueIdCalculator.calculateBlueId(readNode(equivalents)));
+ }
+ }
+
+ private static void assertDifferent(String actualBlueId, JsonNode differentInputs) {
+ if (differentInputs == null || differentInputs.isNull()) {
+ return;
+ }
+ if (differentInputs.isArray()) {
+ for (JsonNode different : differentInputs) {
+ assertNotEquals(actualBlueId, BlueIdCalculator.calculateBlueId(readNode(different)));
+ }
+ } else {
+ assertNotEquals(actualBlueId, BlueIdCalculator.calculateBlueId(readNode(differentInputs)));
+ }
+ }
+
+ private static void assertCanonicalOverlayIsValidBlueIdInput(Node canonical) {
+ BlueIdCalculator.calculateBlueId(canonical);
+ assertNoCanonicalOverlayControls(canonical, "/", false);
+ }
+
+ private static void assertNoCanonicalOverlayControls(Node node, String path, boolean listElement) {
+ if (node == null) {
+ if (listElement) {
+ throw new AssertionError("Canonical Overlay contains null list element at " + path);
+ }
+ return;
+ }
+ if (node.getBlue() != null) {
+ throw new AssertionError("Canonical Overlay contains blue at " + path);
+ }
+ if (node.getPreviousBlueId() != null) {
+ throw new AssertionError("Canonical Overlay contains $previous at " + path);
+ }
+ if (node.getPosition() != null) {
+ throw new AssertionError("Canonical Overlay contains $pos at " + path);
+ }
+ if (node.getProperties() != null && node.getProperties().containsKey("$replace")) {
+ throw new AssertionError("Canonical Overlay contains $replace at " + path);
+ }
+ if (listElement && Nodes.isEmptyNode(node) && !Nodes.isEmptyPlaceholder(node)) {
+ throw new AssertionError("Canonical Overlay contains empty-object list element at " + path);
+ }
+ assertNoCanonicalOverlayControls(node.getType(), appendPath(path, "type"), false);
+ assertNoCanonicalOverlayControls(node.getItemType(), appendPath(path, "itemType"), false);
+ assertNoCanonicalOverlayControls(node.getKeyType(), appendPath(path, "keyType"), false);
+ assertNoCanonicalOverlayControls(node.getValueType(), appendPath(path, "valueType"), false);
+ assertNoCanonicalOverlayControls(node.getBlue(), appendPath(path, "blue"), false);
+ assertNoCanonicalOverlayControls(node.getContracts(), appendPath(path, "contracts"), false);
+ assertNoCanonicalOverlayControls(node.getSchema(), appendPath(path, "schema"));
+ if (node.getItems() != null) {
+ for (int i = 0; i < node.getItems().size(); i++) {
+ assertNoCanonicalOverlayControls(node.getItems().get(i), appendPath(path, String.valueOf(i)), true);
+ }
+ }
+ if (node.getProperties() != null) {
+ node.getProperties().forEach((key, value) ->
+ assertNoCanonicalOverlayControls(value, appendPath(path, key), false));
+ }
+ }
+
+ private static void assertNoCanonicalOverlayControls(Schema schema, String path) {
+ if (schema == null) {
+ return;
+ }
+ assertNoCanonicalOverlayControls(schema.getRequired(), appendPath(path, "required"), false);
+ assertNoCanonicalOverlayControls(schema.getMinLength(), appendPath(path, "minLength"), false);
+ assertNoCanonicalOverlayControls(schema.getMaxLength(), appendPath(path, "maxLength"), false);
+ assertNoCanonicalOverlayControls(schema.getMinimum(), appendPath(path, "minimum"), false);
+ assertNoCanonicalOverlayControls(schema.getMaximum(), appendPath(path, "maximum"), false);
+ assertNoCanonicalOverlayControls(schema.getExclusiveMinimum(), appendPath(path, "exclusiveMinimum"), false);
+ assertNoCanonicalOverlayControls(schema.getExclusiveMaximum(), appendPath(path, "exclusiveMaximum"), false);
+ assertNoCanonicalOverlayControls(schema.getMultipleOf(), appendPath(path, "multipleOf"), false);
+ assertNoCanonicalOverlayControls(schema.getMinItems(), appendPath(path, "minItems"), false);
+ assertNoCanonicalOverlayControls(schema.getMaxItems(), appendPath(path, "maxItems"), false);
+ assertNoCanonicalOverlayControls(schema.getUniqueItems(), appendPath(path, "uniqueItems"), false);
+ assertNoCanonicalOverlayControls(schema.getMinFields(), appendPath(path, "minFields"), false);
+ assertNoCanonicalOverlayControls(schema.getMaxFields(), appendPath(path, "maxFields"), false);
+ if (schema.getEnum() != null) {
+ for (int i = 0; i < schema.getEnum().size(); i++) {
+ assertNoCanonicalOverlayControls(schema.getEnum().get(i), appendPath(path, "enum/" + i), false);
+ }
+ }
+ }
+
+ private static JsonNode readResource(String resource) {
+ try (InputStream inputStream = BlueConformanceSuiteRunner.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IllegalArgumentException("Missing fixture resource: " + resource);
+ }
+ return UncheckedObjectMapper.YAML_MAPPER.readTree(inputStream);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to read fixture resource: " + resource, e);
+ }
+ }
+
+ private static String readTextResource(String resource) {
+ try (InputStream inputStream = BlueConformanceSuiteRunner.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IllegalArgumentException("Missing publishable resource: " + resource);
+ }
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int read;
+ while ((read = inputStream.read(buffer)) >= 0) {
+ output.write(buffer, 0, read);
+ }
+ return new String(output.toByteArray(), StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to read publishable resource: " + resource, e);
+ }
+ }
+
+ private static Node readNode(JsonNode node) {
+ return UncheckedObjectMapper.YAML_MAPPER.treeToValue(node, Node.class);
+ }
+
+ private static JsonNode requirePresent(JsonNode node, String field) {
+ JsonNode value = node.get(field);
+ if (value == null) {
+ throw new IllegalArgumentException("Fixture is missing required field: " + field);
+ }
+ return value;
+ }
+
+ private static JsonNode requireNonNull(JsonNode node, String field) {
+ JsonNode value = node.get(field);
+ if (value == null || value.isNull()) {
+ throw new IllegalArgumentException("Fixture is missing required field: " + field);
+ }
+ return value;
+ }
+
+ private static String text(JsonNode node, String field, String fallback) {
+ JsonNode value = node.get(field);
+ return value == null || value.isNull() ? fallback : value.asText();
+ }
+
+ private static void assertEquals(Object expected, Object actual) {
+ if (expected == null ? actual != null : !expected.equals(actual)) {
+ throw new AssertionError("Expected " + expected + " but was " + actual);
+ }
+ }
+
+ private static void assertNotEquals(Object unexpected, Object actual) {
+ if (unexpected == null ? actual == null : unexpected.equals(actual)) {
+ throw new AssertionError("Did not expect " + actual);
+ }
+ }
+
+ private static String appendPath(String path, String segment) {
+ if (path == null || path.isEmpty() || "/".equals(path)) {
+ return "/" + segment;
+ }
+ return path + "/" + segment;
+ }
+
+ private static final class FixtureEntry {
+ private final String id;
+ private final String category;
+ private final String path;
+
+ private FixtureEntry(String id, String category, String path) {
+ this.id = id;
+ this.category = category;
+ this.path = path;
+ }
+ }
+}
diff --git a/src/main/java/blue/language/BlueContractsConformanceFailure.java b/src/main/java/blue/language/BlueContractsConformanceFailure.java
new file mode 100644
index 0000000..041a98f
--- /dev/null
+++ b/src/main/java/blue/language/BlueContractsConformanceFailure.java
@@ -0,0 +1,42 @@
+package blue.language;
+
+public final class BlueContractsConformanceFailure {
+
+ private final String fixtureId;
+ private final BlueContractsFixtureCategory category;
+ private final String operation;
+ private final String exceptionClass;
+ private final String message;
+
+ public BlueContractsConformanceFailure(String fixtureId,
+ BlueContractsFixtureCategory category,
+ String operation,
+ String exceptionClass,
+ String message) {
+ this.fixtureId = fixtureId;
+ this.category = category;
+ this.operation = operation;
+ this.exceptionClass = exceptionClass;
+ this.message = message;
+ }
+
+ public String getFixtureId() {
+ return fixtureId;
+ }
+
+ public BlueContractsFixtureCategory getCategory() {
+ return category;
+ }
+
+ public String getOperation() {
+ return operation;
+ }
+
+ public String getExceptionClass() {
+ return exceptionClass;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/blue/language/BlueContractsConformanceReport.java b/src/main/java/blue/language/BlueContractsConformanceReport.java
new file mode 100644
index 0000000..34d7bfd
--- /dev/null
+++ b/src/main/java/blue/language/BlueContractsConformanceReport.java
@@ -0,0 +1,229 @@
+package blue.language;
+
+import blue.language.utils.UncheckedObjectMapper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class BlueContractsConformanceReport {
+
+ public static final String FIXTURE_MANIFEST_RESOURCE = "blue-contracts-1.0/fixtures/manifest.yaml";
+ public static final String BLUE_CONTRACTS_1_0_FIXTURE_PACKAGE_IDENTITY =
+ "sha256:2f197ca3bbdc41b75e772777cc48e51019754347e1bee26b5f3209b71d9bd9ca";
+
+ private final String specVersion;
+ private final String fixturePackageIdentity;
+ private final List fixtureIds;
+ private final List passedFixtureIds;
+ private final List failedFixtureIds;
+ private final Map fixtureCategories;
+ private final List failures;
+
+ public BlueContractsConformanceReport(String specVersion,
+ String fixturePackageIdentity,
+ List fixtureIds,
+ List passedFixtureIds,
+ List failedFixtureIds,
+ Map fixtureCategories,
+ List failures) {
+ this.specVersion = specVersion;
+ this.fixturePackageIdentity = fixturePackageIdentity;
+ this.fixtureIds = Collections.unmodifiableList(new ArrayList<>(fixtureIds));
+ this.passedFixtureIds = Collections.unmodifiableList(new ArrayList<>(passedFixtureIds));
+ List effectiveFailed = new ArrayList<>(failedFixtureIds);
+ if (failures != null && !failures.isEmpty()) {
+ effectiveFailed.clear();
+ for (BlueContractsConformanceFailure failure : failures) {
+ effectiveFailed.add(failure.getFixtureId());
+ }
+ }
+ this.failedFixtureIds = Collections.unmodifiableList(effectiveFailed);
+ this.fixtureCategories = Collections.unmodifiableMap(new LinkedHashMap<>(fixtureCategories));
+ this.failures = Collections.unmodifiableList(new ArrayList<>(
+ failures != null ? failures : Collections.emptyList()));
+ }
+
+ public String getSpecVersion() {
+ return specVersion;
+ }
+
+ public String getFixturePackageIdentity() {
+ return fixturePackageIdentity;
+ }
+
+ public List getFixtureIds() {
+ return fixtureIds;
+ }
+
+ public List getPassedFixtureIds() {
+ return passedFixtureIds;
+ }
+
+ public List getFailedFixtureIds() {
+ return failedFixtureIds;
+ }
+
+ public Map getFixtureCategories() {
+ return fixtureCategories;
+ }
+
+ public List getFailures() {
+ return failures;
+ }
+
+ public boolean hasRequiredFixtureCoverage() {
+ return fixtureIds.containsAll(requiredFixtureIdsForContracts10());
+ }
+
+ public boolean hasExactRequiredFixtureSet() {
+ Set fixtureSet = new LinkedHashSet<>(fixtureIds);
+ Set requiredSet = new LinkedHashSet<>(requiredFixtureIdsForContracts10());
+ return fixtureSet.equals(requiredSet) && fixtureIds.size() == requiredSet.size();
+ }
+
+ public boolean isOfficialContracts10FixturePackage() {
+ return BLUE_CONTRACTS_1_0_FIXTURE_PACKAGE_IDENTITY.equals(fixturePackageIdentity);
+ }
+
+ public static List requiredFixtureIdsForContracts10() {
+ return Collections.unmodifiableList(loadFixtureIds());
+ }
+
+ public static String loadFixturePackageIdentity(String fallback) {
+ Map, ?> manifest = loadFixtureManifest();
+ Object identity = manifest != null ? manifest.get("fixturePackageIdentity") : null;
+ return identity == null || identity.toString().trim().isEmpty() ? fallback : identity.toString();
+ }
+
+ public static List loadFixtureIds() {
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null || !(manifest.get("fixtures") instanceof List)) {
+ return Collections.emptyList();
+ }
+ List ids = new ArrayList<>();
+ for (Object fixture : (List>) manifest.get("fixtures")) {
+ if (fixture instanceof Map && ((Map, ?>) fixture).get("id") != null) {
+ ids.add(((Map, ?>) fixture).get("id").toString());
+ }
+ }
+ return ids;
+ }
+
+ public static Map loadFixtureCategories() {
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null || !(manifest.get("fixtures") instanceof List)) {
+ return Collections.emptyMap();
+ }
+ Map categories = new LinkedHashMap<>();
+ for (Object fixture : (List>) manifest.get("fixtures")) {
+ if (fixture instanceof Map) {
+ Map, ?> fixtureMap = (Map, ?>) fixture;
+ Object id = fixtureMap.get("id");
+ Object category = fixtureMap.get("category");
+ if (id != null && category != null) {
+ categories.put(id.toString(), BlueContractsFixtureCategory.fromLabel(category.toString()));
+ }
+ }
+ }
+ return categories;
+ }
+
+ public static String computeFixturePackageIdentity() {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update("manifest.yaml\n".getBytes(StandardCharsets.UTF_8));
+ digest.update(normalizeManifestForIdentity(readFixtureResource(FIXTURE_MANIFEST_RESOURCE)));
+ Map, ?> manifest = loadFixtureManifest();
+ if (manifest == null || !(manifest.get("fixtures") instanceof List)) {
+ throw new IllegalStateException("Blue Contracts fixture manifest has no fixture list");
+ }
+ for (Object fixture : (List>) manifest.get("fixtures")) {
+ if (!(fixture instanceof Map)) {
+ throw new IllegalStateException("Blue Contracts fixture entry must be a map");
+ }
+ Object path = ((Map, ?>) fixture).get("path");
+ if (path == null || path.toString().trim().isEmpty()) {
+ throw new IllegalStateException("Blue Contracts fixture entry is missing path");
+ }
+ String fixturePath = path.toString();
+ digest.update(("\n--- " + fixturePath + "\n").getBytes(StandardCharsets.UTF_8));
+ digest.update(normalizeLineEndings(readFixtureResource("blue-contracts-1.0/fixtures/" + fixturePath)));
+ }
+ return "sha256:" + toHex(digest.digest());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 digest is unavailable", e);
+ }
+ }
+
+ public static boolean fixturePackageIdentityMatchesFixtureFiles() {
+ String identity = loadFixturePackageIdentity(null);
+ return identity != null && identity.equals(computeFixturePackageIdentity());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map, ?> loadFixtureManifest() {
+ try (InputStream inputStream = BlueContractsConformanceReport.class.getClassLoader()
+ .getResourceAsStream(FIXTURE_MANIFEST_RESOURCE)) {
+ if (inputStream == null) {
+ return null;
+ }
+ return UncheckedObjectMapper.YAML_MAPPER.readValue(inputStream, Map.class);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private static byte[] readFixtureResource(String resource) {
+ try (InputStream inputStream = BlueContractsConformanceReport.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IllegalStateException("Missing Blue Contracts fixture resource: " + resource);
+ }
+ return readAll(inputStream);
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to read Blue Contracts fixture resource: " + resource, e);
+ }
+ }
+
+ private static byte[] readAll(InputStream inputStream) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = inputStream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] normalizeManifestForIdentity(byte[] bytes) {
+ String normalized = new String(normalizeLineEndings(bytes), StandardCharsets.UTF_8)
+ .replaceFirst("(?m)^fixturePackageIdentity:.*$", "fixturePackageIdentity: \"\"");
+ return normalized.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static byte[] normalizeLineEndings(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8)
+ .replace("\r\n", "\n")
+ .replace("\r", "\n")
+ .getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static String toHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ builder.append(String.format("%02x", b & 0xff));
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java b/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java
new file mode 100644
index 0000000..26dde0f
--- /dev/null
+++ b/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java
@@ -0,0 +1,1331 @@
+package blue.language;
+
+import blue.language.model.Node;
+import blue.language.conformance.ConformanceEngine;
+import blue.language.processor.ContractMatchingService;
+import blue.language.processor.DocumentProcessingResult;
+import blue.language.processor.DocumentProcessor;
+import blue.language.processor.ProcessingDocumentValidator;
+import blue.language.processor.ProcessorFatalException;
+import blue.language.processor.conformance.MockExternalChannelProcessor;
+import blue.language.processor.conformance.MockHandlerProcessor;
+import blue.language.processor.conformance.MockTypeBlueIds;
+import blue.language.processor.conformance.ScriptedContractsRuntime;
+import blue.language.processor.registry.BlueRuntimeTypeRegistry;
+import blue.language.processor.registry.RuntimeBlueIds;
+import blue.language.processor.registry.RuntimeTypeKey;
+import blue.language.processor.util.PointerUtils;
+import blue.language.utils.NodePathAccessor;
+import blue.language.utils.NodePathEditor;
+import blue.language.utils.NodeProviderWrapper;
+import blue.language.utils.NodeToMapListOrValue;
+import blue.language.utils.UncheckedObjectMapper;
+import blue.language.utils.JsonPointer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class BlueContractsConformanceSuiteRunner {
+
+ private static final String FIXTURE_ROOT = "blue-contracts-1.0/fixtures/";
+ private static final Set SUPPORTED_EXPECTED_FIELDS = new LinkedHashSet<>(Arrays.asList(
+ "expectedAbsentDocumentPathValues",
+ "expectedAbsentDocumentPaths",
+ "expectedBlueId",
+ "expectedCapabilityFailure",
+ "expectedCheckpointLastEvents",
+ "expectedDescendantOrEqual",
+ "expectedDocument",
+ "expectedDocumentPathExists",
+ "expectedDocumentPathValues",
+ "expectedDocumentPaths",
+ "expectedDocumentUpdateOrder",
+ "expectedDocumentUpdates",
+ "expectedEffectApplicationOrder",
+ "expectedEmbeddedDeliveryOrder",
+ "expectedErrorCategories",
+ "expectedErrorCategory",
+ "expectedExactGas",
+ "expectedFailureReasonContains",
+ "expectedGasByteView",
+ "expectedInitializationContentBlueIdInput",
+ "expectedNoDocumentMutation",
+ "expectedOriginalBlueId",
+ "expectedPointerReads",
+ "expectedPointerWrites",
+ "expectedProcessorEventTypes",
+ "expectedRootEventCount",
+ "expectedRootEventPathValues",
+ "expectedRootEventSuffix",
+ "expectedRootEventTypes",
+ "expectedRootEvents",
+ "expectedRuntimeBlueIds",
+ "expectedRuntimeInsertionNormalizedValues",
+ "expectedStatus",
+ "expectedStoredObjectKeys",
+ "expectedTerminationFallback",
+ "expectedTotalGas",
+ "expectedTotalGasMin",
+ "expectedTriggeredDeliveryOrder",
+ "expectedTriggeredFifoAfterDocumentUpdates",
+ "expectedValid"));
+ private static final Set SUPPORTED_PROCESSOR_CAPABILITIES = new LinkedHashSet<>(Arrays.asList(
+ "blue-contracts-fixture-scripted-runtime-v1",
+ "blue-contracts-fixture-type-graph-v1"));
+
+ private BlueContractsConformanceSuiteRunner() {
+ }
+
+ public static BlueContractsConformanceReport run(Blue blue) {
+ BlueContractsConformanceReport metadata = blue.contractsConformanceReport();
+ List passed = new ArrayList<>();
+ List failures = new ArrayList<>();
+ for (FixtureEntry fixture : fixtureEntries()) {
+ try {
+ runFixture(fixture);
+ passed.add(fixture.id);
+ } catch (RuntimeException | AssertionError e) {
+ failures.add(failure(fixture, e));
+ }
+ }
+ return new BlueContractsConformanceReport(
+ metadata.getSpecVersion(),
+ metadata.getFixturePackageIdentity(),
+ metadata.getFixtureIds(),
+ passed,
+ Collections.emptyList(),
+ metadata.getFixtureCategories(),
+ failures);
+ }
+
+ public static void validateFixtureMetadataForTest(JsonNode spec) {
+ validateFixtureMetadata(spec);
+ }
+
+ public static void runFixtureSpecForTest(JsonNode spec) {
+ validateFixtureMetadata(spec);
+ String operation = requireNonNull(spec, "operation").asText();
+ if ("registryRuntimeTypeBlueIds".equals(operation)) {
+ runRegistryFixture(spec);
+ } else if ("changingRegistryDescriptionChangesBlueId".equals(operation)) {
+ runChangingRegistryDescriptionFixture(spec);
+ } else if ("runtimeRegistryPreprocessingEnvironmentReproducible".equals(operation)) {
+ runRuntimeRegistryPreprocessingEnvironmentFixture(spec);
+ } else if ("registryNodeHashesToPublishedBlueId".equals(operation)) {
+ runRegistryNodeHashesFixture(spec);
+ } else if ("registryFieldUsesTextBlueIdString".equals(operation)) {
+ runRegistryFieldUsesTextBlueIdStringFixture(spec);
+ } else if ("processDocument".equals(operation)) {
+ runProcessFixture(spec);
+ } else if ("pointerDescendant".equals(operation)) {
+ runPointerFixture(spec);
+ } else if ("pointerValidation".equals(operation)) {
+ runPointerValidationFixture(spec);
+ } else {
+ throw new IllegalArgumentException("Unsupported Blue Contracts fixture operation: " + operation);
+ }
+ }
+
+ private static List fixtureEntries() {
+ JsonNode manifest = readResource(FIXTURE_ROOT + "manifest.yaml");
+ JsonNode fixtures = requireNonNull(manifest, "fixtures");
+ if (!fixtures.isArray()) {
+ throw new IllegalArgumentException("Fixture manifest field \"fixtures\" must be a list.");
+ }
+ List entries = new ArrayList<>();
+ for (JsonNode entry : fixtures) {
+ String id = requireNonNull(entry, "id").asText();
+ String category = requireNonNull(entry, "category").asText();
+ BlueContractsFixtureCategory.fromLabel(category);
+ String path = requireNonNull(entry, "path").asText();
+ entries.add(new FixtureEntry(id, category, path));
+ }
+ return entries;
+ }
+
+ private static void runFixture(FixtureEntry fixture) {
+ JsonNode spec = readResource(FIXTURE_ROOT + fixture.path);
+ validateFixtureMatchesManifest(fixture, spec);
+ String operation = text(spec, "operation", null);
+ if ("registryRuntimeTypeBlueIds".equals(operation)) {
+ runRegistryFixture(spec);
+ } else if ("changingRegistryDescriptionChangesBlueId".equals(operation)) {
+ runChangingRegistryDescriptionFixture(spec);
+ } else if ("runtimeRegistryPreprocessingEnvironmentReproducible".equals(operation)) {
+ runRuntimeRegistryPreprocessingEnvironmentFixture(spec);
+ } else if ("registryNodeHashesToPublishedBlueId".equals(operation)) {
+ runRegistryNodeHashesFixture(spec);
+ } else if ("registryFieldUsesTextBlueIdString".equals(operation)) {
+ runRegistryFieldUsesTextBlueIdStringFixture(spec);
+ } else if ("processDocument".equals(operation)) {
+ runProcessFixture(spec);
+ } else if ("pointerDescendant".equals(operation)) {
+ runPointerFixture(spec);
+ } else if ("pointerValidation".equals(operation)) {
+ runPointerValidationFixture(spec);
+ } else {
+ throw new IllegalArgumentException("Unsupported Blue Contracts fixture operation: " + operation);
+ }
+ }
+
+ private static void runRegistryFixture(JsonNode spec) {
+ BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault();
+ JsonNode expected = requireNonNull(spec, "expectedRuntimeBlueIds");
+ for (Iterator> it = expected.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ RuntimeTypeKey key = RuntimeTypeKey.valueOf(entry.getKey());
+ assertEquals(entry.getValue().asText(), registry.blueId(key));
+ assertTrue(registry.isProcessorManagedTypeBlueId(entry.getValue().asText()),
+ "Runtime type BlueId must be processor-managed: " + entry.getKey());
+ }
+ }
+
+ private static void runChangingRegistryDescriptionFixture(JsonNode spec) {
+ BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault();
+ RuntimeTypeKey key = runtimeTypeKey(requireNonNull(spec, "registryKey").asText());
+ assertEquals(requireNonNull(spec, "expectedOriginalBlueId").asText(), registry.blueId(key));
+ Node node = readRegistryNode(requireNonNull(spec, "registryPath").asText());
+ String originalCalculated = blueId(node);
+ JsonNode mutation = requireNonNull(spec, "mutation");
+ String field = requireNonNull(mutation, "field").asText();
+ if (!"description".equals(field)) {
+ throw new IllegalArgumentException("Unsupported registry mutation field: " + field);
+ }
+ node.description((node.getDescription() != null ? node.getDescription() : "")
+ + requireNonNull(mutation, "append").asText());
+ String mutated = blueId(node);
+ if (spec.path("expectBlueIdChanged").asBoolean(false)) {
+ assertTrue(!originalCalculated.equals(mutated),
+ "Expected registry mutation to change BlueId for " + key);
+ }
+ }
+
+ private static void runRuntimeRegistryPreprocessingEnvironmentFixture(JsonNode spec) {
+ BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault();
+ JsonNode environment = requireNonNull(spec, "preprocessingEnvironment");
+ assertEquals("blue-language-1.0", requireNonNull(environment, "coreRegistry").asText());
+ assertEquals("blue-contracts-1.0", requireNonNull(environment, "runtimeRegistry").asText());
+ assertEquals(RuntimeTypeKey.values().length, registry.blueIds().size());
+ for (RuntimeTypeKey key : RuntimeTypeKey.values()) {
+ assertEquals(RuntimeBlueIds.blueId(key), registry.blueId(key));
+ }
+ }
+
+ private static void runRegistryNodeHashesFixture(JsonNode spec) {
+ BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault();
+ RuntimeTypeKey key = runtimeTypeKey(requireNonNull(spec, "registryKey").asText());
+ assertEquals(requireNonNull(spec, "expectedBlueId").asText(), registry.blueId(key));
+ readRegistryNode(requireNonNull(spec, "registryPath").asText());
+ }
+
+ private static void runRegistryFieldUsesTextBlueIdStringFixture(JsonNode spec) {
+ JsonNode fields = requireNonNull(spec, "fields");
+ if (!fields.isArray()) {
+ throw new IllegalArgumentException("registryFieldUsesTextBlueIdString fields must be a list");
+ }
+ for (JsonNode fieldSpec : fields) {
+ runtimeTypeKey(requireNonNull(fieldSpec, "registryKey").asText());
+ Node node = readRegistryNode(requireNonNull(fieldSpec, "registryPath").asText());
+ Node field = nodeAt(node, requireNonNull(fieldSpec, "fieldPath").asText());
+ if (field == null) {
+ throw new AssertionError("Missing registry field " + fieldSpec.get("fieldPath").asText());
+ }
+ String expectedType = requireNonNull(fieldSpec, "expectedType").asText();
+ Node type = field.getType();
+ String actualType = type == null ? null
+ : type.getValue() != null ? type.getValue().toString()
+ : type.getBlueId();
+ assertEquals(expectedType, actualType);
+ String phrase = requireNonNull(fieldSpec, "expectedDescriptionContains").asText();
+ String description = field.getDescription();
+ assertTrue(description != null && description.contains(phrase),
+ "Expected registry field description to contain " + phrase);
+ }
+ }
+
+ private static void runPointerFixture(JsonNode spec) {
+ String path = requireNonNull(spec, "path").asText();
+ String ancestor = requireNonNull(spec, "ancestor").asText();
+ boolean expected = requireNonNull(spec, "expectedDescendantOrEqual").asBoolean();
+ assertEquals(expected, PointerUtils.descendantOrEqual(path, ancestor));
+ }
+
+ private static void runPointerValidationFixture(JsonNode spec) {
+ String pointer = requireNonNull(spec, "pointer").asText();
+ boolean expected = requireNonNull(spec, "expectedValid").asBoolean();
+ try {
+ PointerUtils.assertValidRuntimePointer(pointer);
+ assertTrue(expected, "Expected pointer to be invalid: " + pointer);
+ } catch (RuntimeException ex) {
+ if (expected) {
+ throw ex;
+ }
+ assertFailureReasonContains(spec, ex.getMessage());
+ }
+ }
+
+ private static void runProcessFixture(JsonNode spec) {
+ JsonNode initialDocument = requireNonNull(spec, "initialDocument");
+ DocumentProcessingResult rawPreValidationFailure = ProcessingDocumentValidator.validateRaw(initialDocument, null);
+ if (rawPreValidationFailure != null) {
+ assertProcessResult(spec, rawPreValidationFailure.document().clone(), rawPreValidationFailure, null);
+ return;
+ }
+ Node document = ProcessingDocumentValidator.readProcessingDocument(initialDocument);
+ DocumentProcessingResult preValidationFailure = ProcessingDocumentValidator.validateRaw(initialDocument, document);
+ if (preValidationFailure != null) {
+ assertProcessResult(spec, document.clone(), preValidationFailure, null);
+ return;
+ }
+ ScriptedFixtureTypes scriptedTypes = discoverScriptedRuntimeTypes(spec, document);
+ ScriptedContractsRuntime scriptedRuntime = new ScriptedContractsRuntime(spec.get("mockRuntime"), spec.get("typeGraph"));
+ MockExternalChannelProcessor channelProcessor = new MockExternalChannelProcessor(scriptedRuntime);
+ MockHandlerProcessor handlerProcessor = new MockHandlerProcessor(scriptedRuntime);
+ DocumentProcessor.Builder processorBuilder = DocumentProcessor.builder()
+ .withMatchingService(new ContractMatchingService(new Blue()))
+ .registerContractProcessor(channelProcessor)
+ .registerContractProcessor(handlerProcessor);
+ if (!scriptedTypes.externalTypeNodesByBlueId.isEmpty()) {
+ Blue typeGraphBlue = new Blue(mockTypeProvider(scriptedTypes));
+ processorBuilder.withConformanceEngine(new ConformanceEngine(
+ mockTypeProvider(scriptedTypes),
+ typeGraphBlue.getMergingProcessor()));
+ }
+ if (scriptedRuntime.hasFixtureTypeGraph()) {
+ processorBuilder.withConformancePlannerOverride(scriptedRuntime.conformancePlannerOverride());
+ }
+ for (String channelTypeBlueId : scriptedTypes.channelTypeBlueIds) {
+ processorBuilder.registerContractProcessor(channelTypeBlueId, channelProcessor);
+ }
+ for (String handlerTypeBlueId : scriptedTypes.handlerTypeBlueIds) {
+ processorBuilder.registerContractProcessor(handlerTypeBlueId, handlerProcessor);
+ }
+ DocumentProcessor processor = processorBuilder.build();
+ Node originalDocument = document.clone();
+ Node event = spec.has("event") ? readNode(spec.get("event")) : new Node().value("event");
+ try (ScriptedContractsRuntime.Activation ignored = scriptedRuntime.activate()) {
+ DocumentProcessingResult result = processor.processDocument(document, event);
+ assertProcessResult(spec, originalDocument, result, scriptedRuntime);
+ } catch (ProcessorFatalException ex) {
+ DocumentProcessingResult result = ex.partialResult();
+ if (result == null) {
+ throw ex;
+ }
+ assertProcessResult(spec, originalDocument, result, scriptedRuntime);
+ }
+ }
+
+ private static Node withoutProcessorManagedMutationMarkers(Node document) {
+ Node copy = document != null ? document.clone() : new Node();
+ stripProcessorManagedMarkers(copy);
+ return copy;
+ }
+
+ private static void stripProcessorManagedMarkers(Node node) {
+ if (node == null) {
+ return;
+ }
+ if (node.getContracts() != null && node.getContracts().getProperties() != null) {
+ node.getContracts().getProperties().remove("initialized");
+ node.getContracts().getProperties().remove("checkpoint");
+ node.getContracts().getProperties().remove("terminated");
+ for (Node contract : node.getContracts().getProperties().values()) {
+ stripProcessorManagedMarkers(contract);
+ }
+ }
+ if (node.getProperties() != null) {
+ for (Node child : node.getProperties().values()) {
+ stripProcessorManagedMarkers(child);
+ }
+ }
+ if (node.getItems() != null) {
+ for (Node child : node.getItems()) {
+ stripProcessorManagedMarkers(child);
+ }
+ }
+ }
+
+ private static void assertProcessResult(JsonNode spec,
+ Node originalDocument,
+ DocumentProcessingResult result,
+ ScriptedContractsRuntime scriptedRuntime) {
+ assertStatusAndError(spec, result);
+ if (spec.has("expectedCapabilityFailure")) {
+ assertEquals(spec.get("expectedCapabilityFailure").asBoolean(), result.capabilityFailure());
+ }
+ assertFailureReasonContains(spec, result.failureReason(), result.document());
+ if (spec.path("expectedNoDocumentMutation").asBoolean(false)) {
+ assertNodeEquals(withoutProcessorManagedMutationMarkers(originalDocument),
+ withoutProcessorManagedMutationMarkers(result.document()),
+ "Document mutation");
+ }
+ if (spec.has("expectedDocument")) {
+ assertNodeEquals(readNode(spec.get("expectedDocument")), result.document(), "Document");
+ }
+ if (spec.has("expectedExactGas")) {
+ assertEquals(spec.get("expectedExactGas").asLong(), result.totalGas());
+ }
+ if (spec.has("expectedTotalGas")) {
+ assertEquals(spec.get("expectedTotalGas").asLong(), result.totalGas());
+ }
+ if (spec.has("expectedTotalGasMin")) {
+ long min = spec.get("expectedTotalGasMin").asLong();
+ assertTrue(result.totalGas() >= min, "Expected total gas >= " + min + " but was " + result.totalGas());
+ }
+ assertRootEvents(spec, result.triggeredEvents());
+ assertDocumentPaths(spec, result.document());
+ assertCheckpointLastEvents(spec, result.document());
+ assertStoredObjectKeys(spec, result.document());
+ assertPointerReadsAndWrites(spec, result.document());
+ assertInitializationContentBlueIdInput(spec, originalDocument, result.document());
+ assertRuntimeInsertionNormalizedValues(spec, result);
+ assertGasByteView(spec, result);
+ assertProcessorEventTypes(spec, result);
+ assertTerminationFallback(spec, result);
+ assertTraceExpectations(spec, scriptedRuntime);
+ }
+
+ private static void assertRootEvents(JsonNode spec, List rootEvents) {
+ if (spec.has("expectedRootEventCount")) {
+ assertEquals(spec.get("expectedRootEventCount").asInt(), rootEvents.size());
+ }
+ if (spec.has("expectedRootEvents")) {
+ JsonNode expectedEvents = spec.get("expectedRootEvents");
+ if (!expectedEvents.isArray()) {
+ throw new AssertionError("expectedRootEvents must be a list");
+ }
+ assertEquals(expectedEvents.size(), rootEvents.size(), "Root event count");
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ assertNodeEquals(readNode(expectedEvents.get(i)), rootEvents.get(i), "Root event " + i);
+ }
+ }
+ if (spec.has("expectedRootEventSuffix")) {
+ JsonNode expectedEvents = spec.get("expectedRootEventSuffix");
+ if (!expectedEvents.isArray()) {
+ throw new AssertionError("expectedRootEventSuffix must be a list");
+ }
+ if (rootEvents.size() < expectedEvents.size()) {
+ throw new AssertionError("Expected at least " + expectedEvents.size()
+ + " root event(s) for suffix comparison but found " + rootEvents.size());
+ }
+ int offset = rootEvents.size() - expectedEvents.size();
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ assertNodeEquals(readNode(expectedEvents.get(i)), rootEvents.get(offset + i),
+ "Root event suffix " + i);
+ }
+ }
+ if (spec.has("expectedRootEventPathValues")) {
+ JsonNode assertions = spec.get("expectedRootEventPathValues");
+ if (!assertions.isArray()) {
+ throw new AssertionError("expectedRootEventPathValues must be a list");
+ }
+ for (JsonNode assertion : assertions) {
+ int index = requireNonNull(assertion, "index").asInt();
+ String path = requireNonNull(assertion, "path").asText();
+ if (index < 0 || index >= rootEvents.size()) {
+ throw new AssertionError("Expected root event index " + index + " but only "
+ + rootEvents.size() + " event(s) exist");
+ }
+ Node actual = nodeAt(rootEvents.get(index), path);
+ Node expected = readNode(requireNonNull(assertion, "value"));
+ assertNodeEquals(expected, actual, "Root event " + index + " path " + path);
+ }
+ }
+ if (!spec.has("expectedRootEventTypes")) {
+ return;
+ }
+ JsonNode expectedTypes = spec.get("expectedRootEventTypes");
+ assertEquals(expectedTypes.size(), rootEvents.size());
+ for (int i = 0; i < expectedTypes.size(); i++) {
+ Node type = rootEvents.get(i).getType();
+ String actual = type != null ? type.getBlueId() : null;
+ assertEquals(expectedTypes.get(i).asText(), actual);
+ }
+ }
+
+ private static List withoutLeadingProcessorEvents(List events) {
+ int index = 0;
+ while (index < events.size() && isProcessorEvent(events.get(index))) {
+ index++;
+ }
+ return index == 0 ? events : events.subList(index, events.size());
+ }
+
+ private static boolean isProcessorEvent(Node event) {
+ Node type = event != null ? event.getType() : null;
+ String blueId = type != null ? type.getBlueId() : null;
+ return RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals(blueId)
+ || RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED.equals(blueId)
+ || RuntimeBlueIds.DOCUMENT_PROCESSING_FATAL_ERROR.equals(blueId)
+ || RuntimeBlueIds.DOCUMENT_UPDATE.equals(blueId);
+ }
+
+ private static void assertDocumentPaths(JsonNode spec, Node document) {
+ JsonNode exists = spec.get("expectedDocumentPathExists");
+ if (exists != null && exists.isArray()) {
+ for (JsonNode path : exists) {
+ Node actual = nodeAt(document, path.asText());
+ if (actual == null) {
+ throw new AssertionError("Expected document path to exist: " + path.asText());
+ }
+ }
+ }
+ JsonNode absent = spec.get("expectedAbsentDocumentPaths");
+ if (absent != null && absent.isArray()) {
+ for (JsonNode path : absent) {
+ Node actual = nodeAt(document, path.asText());
+ if (actual != null) {
+ throw new AssertionError("Expected document path to be absent: " + path.asText()
+ + " but found " + nodeDebug(actual));
+ }
+ }
+ }
+ JsonNode paths = spec.get("expectedDocumentPaths");
+ if (paths != null && !paths.isNull()) {
+ for (Iterator> it = paths.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ Node actual = nodeAt(document, entry.getKey());
+ if (actual == null) {
+ throw new AssertionError("Expected document path " + entry.getKey()
+ + " in " + nodeDebug(document));
+ }
+ Node expected = readNode(entry.getValue());
+ assertNodeEquals(expected, actual, "Document path " + entry.getKey()
+ + " in " + nodeDebug(document));
+ }
+ }
+
+ JsonNode pathValues = spec.get("expectedDocumentPathValues");
+ if (pathValues != null && pathValues.isArray()) {
+ for (JsonNode assertion : pathValues) {
+ String path = requireNonNull(assertion, "path").asText();
+ Node actual = nodeAt(document, path);
+ if (actual == null) {
+ throw new AssertionError("Expected document path " + path
+ + " in " + nodeDebug(document));
+ }
+ Node expected = readNode(requireNonNull(assertion, "value"));
+ assertNodeEquals(expected, actual, "Document path " + path
+ + " in " + nodeDebug(document));
+ }
+ }
+
+ JsonNode absentPathValues = spec.get("expectedAbsentDocumentPathValues");
+ if (absentPathValues != null && absentPathValues.isArray()) {
+ for (JsonNode assertion : absentPathValues) {
+ String path = requireNonNull(assertion, "path").asText();
+ Node actual = nodeAt(document, path);
+ if (actual == null) {
+ continue;
+ }
+ Node forbidden = readNode(requireNonNull(assertion, "value"));
+ Object actualObject = NodeToMapListOrValue.get(actual);
+ Object forbiddenObject = NodeToMapListOrValue.get(forbidden);
+ if (forbiddenObject.equals(actualObject)) {
+ throw new AssertionError("Expected document path " + path
+ + " not to equal " + nodeDebug(forbidden));
+ }
+ }
+ }
+ }
+
+ private static void assertCheckpointLastEvents(JsonNode spec, Node document) {
+ JsonNode expected = spec.get("expectedCheckpointLastEvents");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ for (Iterator> it = expected.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ String pointer = "/contracts/checkpoint/lastEvents/" + PointerUtils.escapeSegment(entry.getKey());
+ Node actual = nodeAt(document, pointer);
+ if (actual == null) {
+ throw new AssertionError("Expected checkpoint lastEvent for channel " + entry.getKey());
+ }
+ assertNodeEquals(readExpectedNode(entry.getValue()), actual, "Checkpoint lastEvent " + entry.getKey());
+ }
+ }
+
+ private static void assertStatusAndError(JsonNode spec, DocumentProcessingResult result) {
+ if (spec.has("expectedStatus")) {
+ assertEquals(spec.get("expectedStatus").asText(), actualStatus(result), "Processing status");
+ }
+ if (spec.has("expectedErrorCategory")) {
+ assertEquals(spec.get("expectedErrorCategory").asText(), actualErrorCategory(result), "Error category");
+ }
+ if (spec.has("expectedErrorCategories")) {
+ JsonNode categories = spec.get("expectedErrorCategories");
+ String actual = actualErrorCategory(result);
+ boolean matched = false;
+ for (JsonNode category : categories) {
+ if (category.asText().equals(actual)) {
+ matched = true;
+ break;
+ }
+ }
+ assertTrue(matched, "Expected error category " + actual + " to be one of " + categories);
+ }
+ }
+
+ private static String actualStatus(DocumentProcessingResult result) {
+ if (result.status() == null) {
+ throw new AssertionError("Processor result did not provide a typed status");
+ }
+ return result.status().wireValue();
+ }
+
+ private static String actualErrorCategory(DocumentProcessingResult result) {
+ return result.errorCategory() != null
+ ? result.errorCategory().name()
+ : null;
+ }
+
+ private static void assertStoredObjectKeys(JsonNode spec, Node document) {
+ JsonNode expected = spec.get("expectedStoredObjectKeys");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ for (Iterator> it = expected.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ Node object = nodeAt(document, entry.getKey());
+ assertTrue(object != null && object.getProperties() != null,
+ "Expected object at " + entry.getKey());
+ for (JsonNode key : entry.getValue()) {
+ assertTrue(object.getProperties().containsKey(key.asText()),
+ "Expected raw object key " + key.asText() + " at " + entry.getKey());
+ }
+ }
+ }
+
+ private static void assertPointerReadsAndWrites(JsonNode spec, Node document) {
+ assertPointerListAddressesExistingNodes("expectedPointerReads", spec, document);
+ assertPointerListAddressesExistingNodes("expectedPointerWrites", spec, document);
+ }
+
+ private static void assertPointerListAddressesExistingNodes(String field, JsonNode spec, Node document) {
+ JsonNode pointers = spec.get(field);
+ if (pointers == null || !pointers.isArray()) {
+ return;
+ }
+ for (JsonNode pointer : pointers) {
+ Node actual = nodeAt(document, pointer.asText());
+ assertTrue(actual != null, field + " pointer did not address an existing node: " + pointer.asText());
+ }
+ }
+
+ private static void assertInitializationContentBlueIdInput(JsonNode spec, Node originalDocument, Node document) {
+ if (!spec.has("expectedInitializationContentBlueIdInput")) {
+ return;
+ }
+ JsonNode assertion = spec.get("expectedInitializationContentBlueIdInput");
+ String excludesPath = text(assertion, "excludesPath", null);
+ if (excludesPath != null) {
+ assertTrue(nodeAt(originalDocument, excludesPath) == null,
+ "Initialization Content BlueId input unexpectedly included " + excludesPath);
+ }
+ Node documentId = nodeAt(document, "/contracts/initialized/documentId");
+ assertTrue(documentId != null && documentId.getValue() != null,
+ "Initialized marker documentId is missing");
+ assertEquals(blueId(originalDocument), String.valueOf(documentId.getValue()),
+ "Initialization documentId");
+ }
+
+ private static void assertRuntimeInsertionNormalizedValues(JsonNode spec, DocumentProcessingResult result) {
+ JsonNode assertions = spec.get("expectedRuntimeInsertionNormalizedValues");
+ if (assertions == null || !assertions.isArray()) {
+ return;
+ }
+ for (JsonNode assertion : assertions) {
+ Node actual;
+ if (assertion.has("path")) {
+ actual = nodeAt(result.document(), assertion.get("path").asText());
+ } else if (assertion.has("eventIndexFromEnd")) {
+ List events = result.triggeredEvents();
+ int indexFromEnd = assertion.get("eventIndexFromEnd").asInt();
+ int actualIndex = events.size() - 1 - indexFromEnd;
+ actual = actualIndex >= 0 && actualIndex < events.size()
+ ? events.get(actualIndex)
+ : null;
+ } else if (assertion.has("nonProcessorEventIndex")) {
+ List events = withoutLeadingProcessorEvents(result.triggeredEvents());
+ int index = assertion.get("nonProcessorEventIndex").asInt();
+ actual = index >= 0 && index < events.size()
+ ? events.get(index)
+ : null;
+ } else {
+ List events = result.triggeredEvents();
+ int index = requireNonNull(assertion, "eventIndex").asInt();
+ actual = index >= 0 && index < events.size()
+ ? events.get(index)
+ : null;
+ }
+ assertNodeEquals(readNode(requireNonNull(assertion, "selectedDocumentForm")),
+ selectedDocumentForm(actual),
+ "Runtime insertion normalized value");
+ }
+ }
+
+ private static Node selectedDocumentForm(Node node) {
+ if (node == null) {
+ return null;
+ }
+ Node copy = node.clone();
+ if (copy.getType() == null && copy.getValue() instanceof String) {
+ copy.type(new Node().blueId("GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC"));
+ }
+ return copy;
+ }
+
+ private static void assertGasByteView(JsonNode spec, DocumentProcessingResult result) {
+ JsonNode expected = spec.get("expectedGasByteView");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ assertEquals("selected-document-form-after-runtime-insertion-normalization",
+ requireNonNull(expected, "representation").asText(),
+ "Gas byte view representation");
+ if (expected.has("patchValuePath")) {
+ assertTrue(nodeAt(result.document(), expected.get("patchValuePath").asText()) != null,
+ "Gas byte view patch path missing");
+ }
+ if (expected.has("emittedEventIndex")) {
+ int index = expected.get("emittedEventIndex").asInt();
+ assertTrue(index >= 0 && index < result.triggeredEvents().size(),
+ "Gas byte view emitted event index missing");
+ }
+ }
+
+ private static void assertProcessorEventTypes(JsonNode spec, DocumentProcessingResult result) {
+ JsonNode expected = spec.get("expectedProcessorEventTypes");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ assertProcessorEventType(expected, "DocumentUpdate", RuntimeBlueIds.DOCUMENT_UPDATE);
+ assertProcessorEventType(expected, "DocumentProcessingInitiated", RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED);
+ assertProcessorEventType(expected, "DocumentProcessingTerminated", RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED);
+ assertProcessorEventType(expected, "DocumentProcessingFatalError", RuntimeBlueIds.DOCUMENT_PROCESSING_FATAL_ERROR);
+ assertTrue(nodeAt(result.document(), "/contracts/initialized/type/blueId") != null
+ || !result.triggeredEvents().isEmpty(),
+ "Expected processor-created event evidence in result");
+ }
+
+ private static void assertProcessorEventType(JsonNode expected, String name, String blueId) {
+ JsonNode node = expected.get(name);
+ if (node != null && node.has("blueId")) {
+ assertEquals(blueId, node.get("blueId").asText(), name + " BlueId");
+ }
+ }
+
+ private static void assertTerminationFallback(JsonNode spec, DocumentProcessingResult result) {
+ JsonNode expected = spec.get("expectedTerminationFallback");
+ if (expected == null || expected.isNull()) {
+ return;
+ }
+ String targetPath = requireNonNull(expected, "targetPath").asText();
+ assertTrue(nodeAt(result.document(), targetPath) != null,
+ "Termination fallback target path missing: " + targetPath);
+ assertTrue(nodeAt(result.document(), "/contracts/terminated/cause") != null,
+ "Termination fallback did not produce a terminated marker");
+ }
+
+ private static void assertTraceExpectations(JsonNode spec, ScriptedContractsRuntime runtime) {
+ if (spec.has("expectedDocumentUpdateOrder")) {
+ assertEquals(textArray(spec.get("expectedDocumentUpdateOrder")),
+ requireTrace(runtime).documentUpdateOrder(),
+ "Document Update order");
+ }
+ if (spec.has("expectedDocumentUpdates")) {
+ assertDocumentUpdateTrace(spec.get("expectedDocumentUpdates"), requireTrace(runtime));
+ }
+ if (spec.has("expectedEmbeddedDeliveryOrder")) {
+ assertEmbeddedDeliveryOrder(spec.get("expectedEmbeddedDeliveryOrder"), requireTrace(runtime));
+ }
+ if (spec.has("expectedTriggeredDeliveryOrder")) {
+ assertDeliveryOrder(spec.get("expectedTriggeredDeliveryOrder"),
+ requireTrace(runtime).triggeredDeliveryOrder(),
+ "Triggered delivery order");
+ }
+ if (spec.has("expectedEffectApplicationOrder")) {
+ assertEquals(textArray(spec.get("expectedEffectApplicationOrder")),
+ requireTrace(runtime).effectApplicationOrder(),
+ "Effect application order");
+ }
+ if (spec.path("expectedTriggeredFifoAfterDocumentUpdates").asBoolean(false)) {
+ assertTrue(!requireTrace(runtime).documentUpdateOrder().isEmpty(),
+ "Expected Document Updates before Triggered FIFO");
+ }
+ }
+
+ private static ScriptedContractsRuntime requireTrace(ScriptedContractsRuntime runtime) {
+ if (runtime == null) {
+ throw new AssertionError("Fixture expected execution trace, but no scripted runtime trace was collected");
+ }
+ return runtime;
+ }
+
+ private static List textArray(JsonNode array) {
+ List values = new ArrayList<>();
+ if (array != null && array.isArray()) {
+ for (JsonNode item : array) {
+ values.add(item.asText());
+ }
+ }
+ return values;
+ }
+
+ private static void assertDocumentUpdateTrace(JsonNode expected, ScriptedContractsRuntime runtime) {
+ List actual = runtime.documentUpdates();
+ assertEquals(expected.size(), actual.size(), "Document Update trace count");
+ for (int i = 0; i < expected.size(); i++) {
+ JsonNode assertion = expected.get(i);
+ ScriptedContractsRuntime.DocumentUpdateTrace trace = actual.get(i);
+ assertEquals(requireNonNull(assertion, "path").asText(), trace.path(), "Document Update path");
+ if (assertion.has("before")) {
+ JsonNode before = assertion.get("before");
+ if (before.isNull()) {
+ assertEquals(null, trace.before(), "Document Update before");
+ } else {
+ assertNodeEquals(readNode(before), trace.before(), "Document Update before");
+ }
+ }
+ if (assertion.has("after")) {
+ JsonNode after = assertion.get("after");
+ if (after.isNull()) {
+ assertEquals(null, trace.after(), "Document Update after");
+ } else {
+ assertNodeEquals(readNode(after), trace.after(), "Document Update after");
+ }
+ }
+ }
+ }
+
+ private static void assertEmbeddedDeliveryOrder(JsonNode expected, ScriptedContractsRuntime runtime) {
+ if (expected.size() == 0 || expected.get(0).isTextual()) {
+ assertEquals(textArray(expected), runtime.embeddedScopeOrder(), "Embedded scope delivery order");
+ return;
+ }
+ assertDeliveryOrder(expected, runtime.embeddedDeliveryOrder(), "Embedded bridge delivery order");
+ }
+
+ private static void assertDeliveryOrder(JsonNode expected,
+ List actual,
+ String message) {
+ assertEquals(expected.size(), actual.size(), message + " count");
+ for (int i = 0; i < expected.size(); i++) {
+ JsonNode item = expected.get(i);
+ ScriptedContractsRuntime.DeliveryTrace trace = actual.get(i);
+ String event = item.has("event") ? item.get("event").asText()
+ : item.has("emission") ? item.get("emission").asText()
+ : item.asText();
+ assertEquals(event, trace.event(), message + " event " + i);
+ if (item.has("channels")) {
+ assertEquals(textArray(item.get("channels")), trace.channels(), message + " channels " + i);
+ }
+ }
+ }
+
+ private static void validateFixtureMatchesManifest(FixtureEntry fixture, JsonNode spec) {
+ validateFixtureMetadata(spec);
+ assertEquals(fixture.id, requireNonNull(spec, "id").asText());
+ assertEquals(
+ BlueContractsFixtureCategory.fromLabel(fixture.category),
+ BlueContractsFixtureCategory.fromLabel(requireNonNull(spec, "category").asText()));
+ }
+
+ private static void validateFixtureMetadata(JsonNode spec) {
+ requireNonNull(spec, "id");
+ requireNonNull(spec, "category");
+ requireNonNull(spec, "operation");
+ validateExpectedFields(spec);
+ validateProcessorCapabilities(spec);
+ BlueContractsFixtureCategory.fromLabel(requireNonNull(spec, "category").asText());
+ String operation = requireNonNull(spec, "operation").asText();
+ if ("registryRuntimeTypeBlueIds".equals(operation)) {
+ requireNonNull(spec, "expectedRuntimeBlueIds");
+ } else if ("changingRegistryDescriptionChangesBlueId".equals(operation)) {
+ requireNonNull(spec, "registryKey");
+ requireNonNull(spec, "registryPath");
+ requireNonNull(spec, "expectedOriginalBlueId");
+ requireNonNull(spec, "mutation");
+ } else if ("runtimeRegistryPreprocessingEnvironmentReproducible".equals(operation)) {
+ requireNonNull(spec, "preprocessingEnvironment");
+ } else if ("registryNodeHashesToPublishedBlueId".equals(operation)) {
+ requireNonNull(spec, "registryKey");
+ requireNonNull(spec, "registryPath");
+ requireNonNull(spec, "expectedBlueId");
+ } else if ("registryFieldUsesTextBlueIdString".equals(operation)) {
+ requireNonNull(spec, "fields");
+ } else if ("processDocument".equals(operation)) {
+ requireNonNull(spec, "initialDocument");
+ if (!hasMeaningfulProcessAssertion(spec)) {
+ throw new IllegalArgumentException("processDocument fixtures must assert outputs");
+ }
+ } else if ("pointerDescendant".equals(operation)) {
+ requireNonNull(spec, "path");
+ requireNonNull(spec, "ancestor");
+ requireNonNull(spec, "expectedDescendantOrEqual");
+ } else if ("pointerValidation".equals(operation)) {
+ requireNonNull(spec, "pointer");
+ requireNonNull(spec, "expectedValid");
+ } else {
+ throw new IllegalArgumentException("Unsupported Blue Contracts fixture operation: " + operation);
+ }
+ }
+
+ private static boolean hasMeaningfulProcessAssertion(JsonNode spec) {
+ return hasMeaningfulCapabilityFailureAssertion(spec)
+ || spec.has("expectedTotalGas")
+ || spec.has("expectedExactGas")
+ || spec.has("expectedTotalGasMin")
+ || spec.has("expectedDocument")
+ || spec.has("expectedDocumentPaths")
+ || spec.has("expectedDocumentPathValues")
+ || spec.has("expectedDocumentPathExists")
+ || spec.has("expectedAbsentDocumentPaths")
+ || spec.has("expectedAbsentDocumentPathValues")
+ || spec.has("expectedRootEventCount")
+ || spec.has("expectedRootEvents")
+ || spec.has("expectedRootEventTypes")
+ || spec.has("expectedRootEventPathValues")
+ || spec.has("expectedStatus")
+ || spec.has("expectedErrorCategory")
+ || spec.has("expectedErrorCategories")
+ || spec.has("expectedFailureReasonContains")
+ || spec.has("expectedNoDocumentMutation")
+ || spec.has("expectedCheckpointLastEvents")
+ || spec.has("expectedDocumentUpdateOrder")
+ || spec.has("expectedDocumentUpdates")
+ || spec.has("expectedEmbeddedDeliveryOrder")
+ || spec.has("expectedEffectApplicationOrder")
+ || spec.has("expectedTriggeredDeliveryOrder")
+ || spec.has("expectedTriggeredFifoAfterDocumentUpdates")
+ || spec.has("expectedRuntimeInsertionNormalizedValues")
+ || spec.has("expectedGasByteView")
+ || spec.has("expectedProcessorEventTypes")
+ || spec.has("expectedInitializationContentBlueIdInput")
+ || spec.has("expectedPointerReads")
+ || spec.has("expectedPointerWrites")
+ || spec.has("expectedStoredObjectKeys")
+ || spec.has("expectedTerminationFallback");
+ }
+
+ private static void validateExpectedFields(JsonNode spec) {
+ for (Iterator it = spec.fieldNames(); it.hasNext(); ) {
+ String field = it.next();
+ if (field.startsWith("expected") && !SUPPORTED_EXPECTED_FIELDS.contains(field)) {
+ throw new IllegalArgumentException("Unsupported expected fixture field: " + field);
+ }
+ }
+ }
+
+ private static void validateProcessorCapabilities(JsonNode spec) {
+ JsonNode capabilities = spec.get("processorCapabilities");
+ if (capabilities == null || capabilities.isNull()) {
+ return;
+ }
+ if (!capabilities.isArray()) {
+ throw new IllegalArgumentException("processorCapabilities must be a list");
+ }
+ for (JsonNode capability : capabilities) {
+ if (!SUPPORTED_PROCESSOR_CAPABILITIES.contains(capability.asText())) {
+ throw new IllegalArgumentException("Unsupported processor capability: " + capability.asText());
+ }
+ }
+ }
+
+ private static boolean hasMeaningfulCapabilityFailureAssertion(JsonNode spec) {
+ JsonNode expected = spec.get("expectedCapabilityFailure");
+ if (expected == null || !expected.asBoolean(false)) {
+ return false;
+ }
+ return spec.path("expectedNoDocumentMutation").asBoolean(false)
+ || isZero(spec.get("expectedTotalGas"))
+ || isZero(spec.get("expectedExactGas"))
+ || isZero(spec.get("expectedRootEventCount"))
+ || isEmptyArray(spec.get("expectedRootEvents"))
+ || spec.has("expectedFailureReasonContains");
+ }
+
+ private static boolean isZero(JsonNode node) {
+ return node != null && node.isNumber() && node.asLong() == 0L;
+ }
+
+ private static boolean isEmptyArray(JsonNode node) {
+ return node != null && node.isArray() && node.size() == 0;
+ }
+
+ private static BlueContractsConformanceFailure failure(FixtureEntry fixture, Throwable throwable) {
+ String operation = null;
+ try {
+ operation = text(readResource(FIXTURE_ROOT + fixture.path), "operation", null);
+ } catch (RuntimeException ignored) {
+ }
+ return new BlueContractsConformanceFailure(
+ fixture.id,
+ BlueContractsFixtureCategory.fromLabel(fixture.category),
+ operation,
+ throwable.getClass().getName(),
+ throwable.getMessage());
+ }
+
+ private static JsonNode readResource(String resource) {
+ try (InputStream inputStream = BlueContractsConformanceSuiteRunner.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IllegalArgumentException("Missing Blue Contracts fixture resource: " + resource);
+ }
+ return UncheckedObjectMapper.YAML_MAPPER.readTree(inputStream);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to read Blue Contracts fixture resource: " + resource, e);
+ }
+ }
+
+ private static Node readNode(JsonNode node) {
+ try {
+ return UncheckedObjectMapper.JSON_MAPPER.convertValue(node, Node.class);
+ } catch (IllegalArgumentException ex) {
+ JsonNode value = node != null && node.isObject() ? node.get("value") : null;
+ if (value != null && (value.isObject() || value.isArray())) {
+ return readNode(value);
+ }
+ JsonNode unwrapped = unwrapObjectValueWrappers(node);
+ if (unwrapped != node) {
+ return UncheckedObjectMapper.JSON_MAPPER.convertValue(unwrapped, Node.class);
+ }
+ throw ex;
+ }
+ }
+
+ private static JsonNode unwrapObjectValueWrappers(JsonNode node) {
+ if (node == null) {
+ return null;
+ }
+ if (node.isObject()) {
+ JsonNode value = node.get("value");
+ if (node.size() == 1 && value != null && (value.isObject() || value.isArray())) {
+ return unwrapObjectValueWrappers(value);
+ }
+ ObjectNode copy = UncheckedObjectMapper.JSON_MAPPER.createObjectNode();
+ for (Iterator> it = node.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ copy.set(entry.getKey(), unwrapObjectValueWrappers(entry.getValue()));
+ }
+ return copy;
+ }
+ if (node.isArray()) {
+ ArrayNode copy = UncheckedObjectMapper.JSON_MAPPER.createArrayNode();
+ for (JsonNode item : node) {
+ copy.add(unwrapObjectValueWrappers(item));
+ }
+ return copy;
+ }
+ return node;
+ }
+
+ private static Node readExpectedNode(JsonNode node) {
+ return readNode(node);
+ }
+
+ private static Node readRegistryNode(String registryPath) {
+ String normalized = registryPath.startsWith("/") ? registryPath.substring(1) : registryPath;
+ try (InputStream inputStream = BlueContractsConformanceSuiteRunner.class.getClassLoader()
+ .getResourceAsStream(normalized)) {
+ if (inputStream == null) {
+ throw new IllegalArgumentException("Missing runtime registry resource: " + normalized);
+ }
+ return UncheckedObjectMapper.YAML_MAPPER.readValue(inputStream, Node.class);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to read runtime registry resource: " + normalized, e);
+ }
+ }
+
+ private static String blueId(Node node) {
+ return blue.language.utils.BlueIdCalculator.calculateUncheckedBlueId(node);
+ }
+
+ private static RuntimeTypeKey runtimeTypeKey(String key) {
+ StringBuilder result = new StringBuilder();
+ for (int i = 0; i < key.length(); i++) {
+ char ch = key.charAt(i);
+ if (Character.isUpperCase(ch) && i > 0) {
+ result.append('_');
+ }
+ result.append(Character.toUpperCase(ch));
+ }
+ return RuntimeTypeKey.valueOf(result.toString());
+ }
+
+ private static ScriptedFixtureTypes discoverScriptedRuntimeTypes(JsonNode spec, Node document) {
+ ScriptedFixtureTypes types = new ScriptedFixtureTypes();
+ types.addChannel(MockTypeBlueIds.MOCK_EXTERNAL_CHANNEL);
+ types.addChannel(MockTypeBlueIds.LEGACY_MOCK_EXTERNAL_CHANNEL);
+ types.addHandler(MockTypeBlueIds.MOCK_HANDLER);
+ types.addHandler(MockTypeBlueIds.LEGACY_MOCK_HANDLER);
+ JsonNode mockRuntime = spec.get("mockRuntime");
+ JsonNode typeGraph = spec.get("typeGraph");
+ if (typeGraph != null && typeGraph.isObject()) {
+ Map blueIdsByName = new LinkedHashMap<>();
+ for (Iterator> it = typeGraph.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ JsonNode blueId = entry.getValue().get("blueId");
+ if (blueId != null && !blueId.isNull()) {
+ blueIdsByName.put(entry.getKey(), blueId.asText());
+ }
+ }
+ for (Iterator> it = typeGraph.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ String blueId = blueIdsByName.get(entry.getKey());
+ if (blueId != null) {
+ types.addExternalType(blueId, fixtureTypeNode(entry.getKey(), blueId, entry.getValue(), blueIdsByName));
+ }
+ }
+ }
+ if (mockRuntime == null || mockRuntime.isNull()) {
+ return types;
+ }
+ JsonNode channels = mockRuntime.get("channels");
+ if (channels != null && channels.isArray()) {
+ for (JsonNode channel : channels) {
+ String contractPath = requireNonNull(channel, "contract").asText();
+ Node contract = NodePathEditor.getOrNull(document, contractPath);
+ String typeBlueId = typeBlueId(contract);
+ if (typeBlueId != null) {
+ types.addChannel(typeBlueId);
+ }
+ }
+ }
+ JsonNode handlers = mockRuntime.get("handlers");
+ if (handlers != null && handlers.isArray()) {
+ for (JsonNode handler : handlers) {
+ String contractPath = requireNonNull(handler, "contract").asText();
+ Node contract = NodePathEditor.getOrNull(document, contractPath);
+ String typeBlueId = typeBlueId(contract);
+ if (typeBlueId != null) {
+ types.addHandler(typeBlueId);
+ }
+ }
+ }
+ return types;
+ }
+
+ private static String typeBlueId(Node contract) {
+ return contract != null && contract.getType() != null ? contract.getType().getBlueId() : null;
+ }
+
+ private static NodeProvider mockTypeProvider(ScriptedFixtureTypes fixtureTypes) {
+ /*
+ * Fixture-only provider for mock external channel/handler contracts.
+ * Production processor-managed runtime types are resolved through
+ * BlueRuntimeTypeRegistry; this provider is installed only by the
+ * conformance runner for fixture-declared mock type BlueIds.
+ */
+ NodeProvider provider = blueId -> {
+ Node externalType = fixtureTypes.externalTypeNodesByBlueId.get(blueId);
+ if (externalType != null) {
+ return Collections.singletonList(externalType.clone());
+ }
+ if (fixtureTypes.channelTypeBlueIds.contains(blueId)) {
+ return Collections.singletonList(mockTypeNode("MockExternalChannel", blueId));
+ }
+ if (fixtureTypes.handlerTypeBlueIds.contains(blueId)) {
+ return Collections.singletonList(mockTypeNode("MockHandler", blueId));
+ }
+ return null;
+ };
+ return NodeProviderWrapper.unverified(provider);
+ }
+
+ private static Node mockTypeNode(String name, String blueId) {
+ return new Node().blueId(blueId).name(name);
+ }
+
+ private static Node fixtureTypeNode(String name, String blueId, JsonNode spec, Map blueIdsByName) {
+ Node node = new Node().blueId(blueId).name(name);
+ JsonNode parent = spec.get("parent");
+ if (parent != null && !parent.isNull()) {
+ String parentBlueId = blueIdsByName.get(parent.asText());
+ if (parentBlueId == null) {
+ throw new IllegalArgumentException("Unknown fixture type parent: " + parent.asText());
+ }
+ node.type(new Node().blueId(parentBlueId));
+ }
+ JsonNode fixedValues = spec.get("fixedValues");
+ if (fixedValues != null && fixedValues.isObject()) {
+ for (Iterator> it = fixedValues.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ NodePathEditor.put(node, entry.getKey(), readNode(entry.getValue()));
+ }
+ }
+ JsonNode fields = spec.get("fields");
+ if (fields != null && fields.isObject()) {
+ for (Iterator> it = fields.fields(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ JsonNode fieldType = entry.getValue().get("type");
+ if (fieldType == null || fieldType.isNull()) {
+ continue;
+ }
+ String fieldTypeBlueId = blueIdsByName.get(fieldType.asText());
+ if (fieldTypeBlueId == null) {
+ throw new IllegalArgumentException("Unknown fixture field type: " + fieldType.asText());
+ }
+ NodePathEditor.put(node, entry.getKey(), new Node().type(new Node().blueId(fieldTypeBlueId)));
+ }
+ }
+ return node;
+ }
+
+ private static final class ScriptedFixtureTypes {
+ final Set channelTypeBlueIds = new LinkedHashSet<>();
+ final Set handlerTypeBlueIds = new LinkedHashSet<>();
+ final Set allTypeBlueIds = new LinkedHashSet<>();
+ final Map externalTypeNodesByBlueId = new LinkedHashMap<>();
+
+ void addChannel(String blueId) {
+ channelTypeBlueIds.add(blueId);
+ allTypeBlueIds.add(blueId);
+ }
+
+ void addHandler(String blueId) {
+ handlerTypeBlueIds.add(blueId);
+ allTypeBlueIds.add(blueId);
+ }
+
+ void addExternalType(String blueId, Node node) {
+ externalTypeNodesByBlueId.put(blueId, node);
+ allTypeBlueIds.add(blueId);
+ }
+ }
+
+ private static JsonNode requireNonNull(JsonNode node, String field) {
+ JsonNode value = node.get(field);
+ if (value == null || value.isNull()) {
+ throw new IllegalArgumentException("Fixture field \"" + field + "\" is required.");
+ }
+ return value;
+ }
+
+ private static String text(JsonNode node, String field, String fallback) {
+ JsonNode value = node.get(field);
+ return value == null || value.isNull() ? fallback : value.asText();
+ }
+
+ private static void assertNodeEquals(Node expected, Node actual, String message) {
+ if (expected == null || actual == null) {
+ assertEquals(expected, actual, message);
+ return;
+ }
+ Object expectedObject = NodeToMapListOrValue.get(expected);
+ Object actualObject = NodeToMapListOrValue.get(actual);
+ assertEquals(expectedObject, actualObject, message);
+ }
+
+ private static void assertFailureReasonContains(JsonNode spec, String actualReason) {
+ assertFailureReasonContains(spec, actualReason, null);
+ }
+
+ private static void assertFailureReasonContains(JsonNode spec, String actualReason, Node document) {
+ if (!spec.has("expectedFailureReasonContains")) {
+ return;
+ }
+ String expected = spec.get("expectedFailureReasonContains").asText();
+ if (actualReason != null && actualReason.contains(expected)) {
+ return;
+ }
+ if (document != null && nodeDebug(document).contains(expected)) {
+ return;
+ }
+ throw new AssertionError("Expected failure reason to contain <" + expected
+ + "> but was <" + actualReason + ">");
+ }
+
+ private static Node nodeAt(Node document, String pointer) {
+ try {
+ if (pointer == null || !pointer.startsWith("/")) {
+ throw new IllegalArgumentException("Invalid path: " + pointer);
+ }
+ if ("/".equals(pointer)) {
+ return document;
+ }
+ Node current = document;
+ for (String segment : JsonPointer.split(pointer)) {
+ if (current == null) {
+ return null;
+ }
+ if (current.getProperties() != null && current.getProperties().containsKey(segment)) {
+ current = current.getProperties().get(segment);
+ } else if (JsonPointer.isArrayIndexSegment(segment) && current.getItems() != null) {
+ int index = Integer.parseInt(segment);
+ current = index >= 0 && index < current.getItems().size()
+ ? current.getItems().get(index)
+ : null;
+ } else if ("type".equals(segment)) {
+ current = current.getType();
+ } else if ("itemType".equals(segment)) {
+ current = current.getItemType();
+ } else if ("keyType".equals(segment)) {
+ current = current.getKeyType();
+ } else if ("valueType".equals(segment)) {
+ current = current.getValueType();
+ } else if ("value".equals(segment)) {
+ current = current.getRawValue() != null ? new Node().value(current.getRawValue()) : null;
+ } else if ("blueId".equals(segment)) {
+ current = new Node().value(blueId(current));
+ } else if ("contracts".equals(segment)) {
+ current = current.getContracts();
+ } else {
+ return null;
+ }
+ }
+ return current;
+ } catch (RuntimeException ex) {
+ return null;
+ }
+ }
+
+ private static String nodeDebug(Node node) {
+ try {
+ return UncheckedObjectMapper.JSON_MAPPER.writeValueAsString(NodeToMapListOrValue.get(node));
+ } catch (Exception ex) {
+ return String.valueOf(node);
+ }
+ }
+
+ private static void assertEquals(Object expected, Object actual) {
+ assertEquals(expected, actual, null);
+ }
+
+ private static void assertEquals(Object expected, Object actual, String message) {
+ if (expected == null ? actual != null : !expected.equals(actual)) {
+ throw new AssertionError((message != null ? message + ": " : "")
+ + "expected <" + expected + "> but was <" + actual + ">");
+ }
+ }
+
+ private static void assertTrue(boolean value, String message) {
+ if (!value) {
+ throw new AssertionError(message);
+ }
+ }
+
+ private static final class FixtureEntry {
+ private final String id;
+ private final String category;
+ private final String path;
+
+ FixtureEntry(String id, String category, String path) {
+ this.id = id;
+ this.category = category;
+ this.path = path;
+ }
+ }
+}
diff --git a/src/main/java/blue/language/BlueContractsFixtureCategory.java b/src/main/java/blue/language/BlueContractsFixtureCategory.java
new file mode 100644
index 0000000..0c851ff
--- /dev/null
+++ b/src/main/java/blue/language/BlueContractsFixtureCategory.java
@@ -0,0 +1,36 @@
+package blue.language;
+
+import java.util.Locale;
+
+public enum BlueContractsFixtureCategory {
+ REGISTRY,
+ CONTRACT_KEY,
+ PROCESSING_DOCUMENT,
+ MUST_UNDERSTAND,
+ INITIALIZATION,
+ PATCHING,
+ DOCUMENT_UPDATE,
+ EFFECTS,
+ EVENTS,
+ TRIGGERED_FIFO,
+ EMBEDDED,
+ CHECKPOINT,
+ GENERALIZATION,
+ TERMINATION,
+ NORMALIZATION,
+ GAS,
+ DISPATCH_SNAPSHOT,
+ POINTER;
+
+ public static BlueContractsFixtureCategory fromLabel(String label) {
+ if (label == null) {
+ throw new IllegalArgumentException("Fixture category is required");
+ }
+ String normalized = label.trim()
+ .replaceAll("([a-z])([A-Z])", "$1_$2")
+ .replace('-', '_')
+ .replace(' ', '_')
+ .toUpperCase(Locale.ROOT);
+ return BlueContractsFixtureCategory.valueOf(normalized);
+ }
+}
diff --git a/src/main/java/blue/language/BlueFixtureCategory.java b/src/main/java/blue/language/BlueFixtureCategory.java
new file mode 100644
index 0000000..183132c
--- /dev/null
+++ b/src/main/java/blue/language/BlueFixtureCategory.java
@@ -0,0 +1,38 @@
+package blue.language;
+
+import java.util.Locale;
+
+public enum BlueFixtureCategory {
+ BLUE_ID("BlueId"),
+ SERIALIZATION("Serialization"),
+ SCHEMA("Schema"),
+ RESOLUTION("Resolution"),
+ CANONICALIZATION("Canonicalization"),
+ PROVIDER("Provider"),
+ CIRCULAR("Circular"),
+ REGISTRY("Registry"),
+ DOCUMENTATION_LINT("DocumentationLint");
+
+ private final String label;
+
+ BlueFixtureCategory(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public static BlueFixtureCategory fromLabel(String value) {
+ if (value == null) {
+ throw new IllegalArgumentException("Fixture category is required.");
+ }
+ String normalized = value.replace("-", "_").replace(" ", "_").toUpperCase(Locale.ROOT);
+ for (BlueFixtureCategory category : values()) {
+ if (category.name().equals(normalized) || category.label.equalsIgnoreCase(value)) {
+ return category;
+ }
+ }
+ throw new IllegalArgumentException("Unknown Blue fixture category: " + value);
+ }
+}
diff --git a/src/main/java/blue/language/BlueLanguageErrorCategory.java b/src/main/java/blue/language/BlueLanguageErrorCategory.java
new file mode 100644
index 0000000..ea68af5
--- /dev/null
+++ b/src/main/java/blue/language/BlueLanguageErrorCategory.java
@@ -0,0 +1,21 @@
+package blue.language;
+
+public enum BlueLanguageErrorCategory {
+ InvalidSyntax,
+ DuplicateKey,
+ InvalidReservedField,
+ InvalidBlueId,
+ InvalidReferenceShape,
+ InvalidBlueIdInput,
+ ProviderUnavailable,
+ ProviderBlueIdMismatch,
+ TypeCycle,
+ FixedValueConflict,
+ TypeCompatibilityViolation,
+ SchemaVocabularyError,
+ SchemaViolation,
+ ListControlViolation,
+ CanonicalizationError,
+ CircularSetError,
+ UnsupportedPreprocessingTransform
+}
diff --git a/src/main/java/blue/language/BlueLanguageErrorClassifier.java b/src/main/java/blue/language/BlueLanguageErrorClassifier.java
new file mode 100644
index 0000000..49cdb58
--- /dev/null
+++ b/src/main/java/blue/language/BlueLanguageErrorClassifier.java
@@ -0,0 +1,132 @@
+package blue.language;
+
+public final class BlueLanguageErrorClassifier {
+
+ private BlueLanguageErrorClassifier() {
+ }
+
+ public static BlueLanguageErrorCategory classify(Throwable throwable) {
+ if (throwable == null) {
+ return BlueLanguageErrorCategory.CanonicalizationError;
+ }
+ String message = messageChain(throwable);
+ String lower = message.toLowerCase();
+
+ if (lower.contains("duplicate key")) {
+ return BlueLanguageErrorCategory.DuplicateKey;
+ }
+ if (lower.contains("provider returned content for")
+ || lower.contains("wrong blueid")
+ || lower.contains("computed blueid")
+ || lower.contains("does not match requested")
+ || lower.contains("requested blueid")
+ || (lower.contains("requested") && lower.contains("blueid"))) {
+ return BlueLanguageErrorCategory.ProviderBlueIdMismatch;
+ }
+ if (lower.contains("provider returned no content")
+ || lower.contains("missing provider content")
+ || lower.contains("missing blue language fixture resource")
+ || lower.contains("missing fixture resource")) {
+ return BlueLanguageErrorCategory.ProviderUnavailable;
+ }
+ if (lower.contains("type cycle")
+ || lower.contains("cyclic type")) {
+ return BlueLanguageErrorCategory.TypeCycle;
+ }
+ if (lower.contains("circular")
+ || lower.contains("cyclic")
+ || lower.contains("preliminary")) {
+ return BlueLanguageErrorCategory.CircularSetError;
+ }
+ if (lower.contains("direct blueid input")
+ || lower.contains("blueid input")
+ || lower.contains("unresolved alias")
+ || lower.contains("type alias")) {
+ return BlueLanguageErrorCategory.InvalidBlueIdInput;
+ }
+ if (lower.contains("$pos")
+ || lower.contains("$replace")
+ || lower.contains("$previous")
+ || lower.contains("$empty")
+ || lower.contains("list control")
+ || lower.contains("positional")
+ || lower.contains("append-only")) {
+ return BlueLanguageErrorCategory.ListControlViolation;
+ }
+ if (lower.contains("wrong kind")) {
+ return BlueLanguageErrorCategory.SchemaViolation;
+ }
+ if (lower.contains("\"schema.")
+ || lower.contains("schema keyword")
+ || lower.contains("schema value")
+ || lower.contains("schema.enum")
+ || lower.contains("schema min")
+ || lower.contains("multipleof must")
+ || lower.contains("minimum must")
+ || lower.contains("exclusiveminimum must")) {
+ return BlueLanguageErrorCategory.SchemaVocabularyError;
+ }
+ if (lower.contains("schema")
+ || lower.contains("minimum")
+ || lower.contains("maximum")
+ || lower.contains("multiple of")
+ || lower.contains("minimum length")
+ || lower.contains("maximum length")
+ || lower.contains("required node")
+ || lower.contains("allowed enum")
+ || lower.contains("wrong kind")) {
+ return BlueLanguageErrorCategory.SchemaViolation;
+ }
+ if (lower.contains("fixed value")
+ || lower.contains("values must not conflict")
+ || lower.contains("value conflict")) {
+ return BlueLanguageErrorCategory.FixedValueConflict;
+ }
+ if (lower.contains("not a subtype")
+ || lower.contains("invalid type")
+ || lower.contains("incompatible")
+ || lower.contains("does not match")
+ || lower.contains("itemtype")
+ || lower.contains("keytype")
+ || lower.contains("valuetype")) {
+ return BlueLanguageErrorCategory.TypeCompatibilityViolation;
+ }
+ if (lower.contains("invalid blueid")
+ || lower.contains("not a valid blueid")
+ || lower.contains("plain valid blueid")
+ || lower.contains("potential blueid")) {
+ return BlueLanguageErrorCategory.InvalidBlueId;
+ }
+ if (lower.contains("reference")
+ || lower.contains("blueid with sibling")
+ || lower.contains("pure reference")) {
+ return BlueLanguageErrorCategory.InvalidReferenceShape;
+ }
+ if (lower.contains("reserved")
+ || lower.contains("\"blue\"")
+ || lower.contains("\"properties\"")
+ || lower.contains("internal field")) {
+ return BlueLanguageErrorCategory.InvalidReservedField;
+ }
+ if (lower.contains("preprocess")
+ || lower.contains("unsupported transform")) {
+ return BlueLanguageErrorCategory.UnsupportedPreprocessingTransform;
+ }
+ if (throwable instanceof com.fasterxml.jackson.core.JsonProcessingException) {
+ return BlueLanguageErrorCategory.InvalidSyntax;
+ }
+ return BlueLanguageErrorCategory.CanonicalizationError;
+ }
+
+ private static String messageChain(Throwable throwable) {
+ StringBuilder builder = new StringBuilder();
+ Throwable current = throwable;
+ while (current != null) {
+ if (current.getMessage() != null) {
+ builder.append(current.getMessage()).append('\n');
+ }
+ current = current.getCause();
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/blue/language/BlueViewPath.java b/src/main/java/blue/language/BlueViewPath.java
new file mode 100644
index 0000000..76f4d72
--- /dev/null
+++ b/src/main/java/blue/language/BlueViewPath.java
@@ -0,0 +1,119 @@
+package blue.language;
+
+import blue.language.model.Node;
+import blue.language.utils.BlueIdCalculator;
+import blue.language.utils.NodeToBlueIdInput;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public final class BlueViewPath {
+
+ private BlueViewPath() {
+ }
+
+ public static List split(String path) {
+ if (path == null) {
+ throw new IllegalArgumentException("Blue Language view path must not be null.");
+ }
+ if (path.isEmpty()) {
+ return new ArrayList<>();
+ }
+ if (!path.startsWith("/")) {
+ throw new IllegalArgumentException("Blue Language view path must be an RFC 6901 JSON Pointer.");
+ }
+ String[] rawSegments = path.substring(1).split("/", -1);
+ List segments = new ArrayList<>(rawSegments.length);
+ for (String raw : rawSegments) {
+ segments.add(unescape(raw));
+ }
+ return segments;
+ }
+
+ public static Node select(Node root, String path) {
+ Node current = root;
+ List segments = split(path);
+ for (int i = 0; i < segments.size(); i++) {
+ current = child(current, segments, i);
+ if (current == null) {
+ throw new IllegalArgumentException("Blue Language view path not found: " + path);
+ }
+ if ("items".equals(segments.get(i))) {
+ i++;
+ }
+ }
+ return current;
+ }
+
+ private static Node child(Node node, List segments, int index) {
+ if (node == null) {
+ return null;
+ }
+ String segment = segments.get(index);
+ switch (segment) {
+ case "name":
+ return new Node().value(node.getName());
+ case "description":
+ return new Node().value(node.getDescription());
+ case "type":
+ return node.getType();
+ case "itemType":
+ return node.getItemType();
+ case "keyType":
+ return node.getKeyType();
+ case "valueType":
+ return node.getValueType();
+ case "value":
+ return new Node().value(node.getRawValue());
+ case "blueId":
+ return new Node().value(BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.getWithResolvedBlueIdMetadata(node)));
+ case "contracts":
+ return node.getContracts();
+ case "items":
+ if (index + 1 >= segments.size()) {
+ return new Node().items(node.getItems());
+ }
+ return item(node, segments.get(index + 1));
+ default:
+ Map properties = node.getProperties();
+ return properties == null ? null : properties.get(segment);
+ }
+ }
+
+ private static Node item(Node node, String indexSegment) {
+ if (node.getItems() == null || !indexSegment.matches("0|[1-9]\\d*")) {
+ return null;
+ }
+ int index;
+ try {
+ index = Integer.parseInt(indexSegment);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ return index < node.getItems().size() ? node.getItems().get(index) : null;
+ }
+
+ private static String unescape(String segment) {
+ StringBuilder builder = new StringBuilder(segment.length());
+ for (int i = 0; i < segment.length(); i++) {
+ char current = segment.charAt(i);
+ if (current != '~') {
+ builder.append(current);
+ continue;
+ }
+ if (i + 1 >= segment.length()) {
+ throw new IllegalArgumentException("Invalid RFC 6901 escape in Blue Language view path.");
+ }
+ char next = segment.charAt(++i);
+ if (next == '0') {
+ builder.append('~');
+ } else if (next == '1') {
+ builder.append('/');
+ } else {
+ throw new IllegalArgumentException("Invalid RFC 6901 escape in Blue Language view path.");
+ }
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/blue/language/conformance/ConformanceEngine.java b/src/main/java/blue/language/conformance/ConformanceEngine.java
index 18a5146..e3541d1 100644
--- a/src/main/java/blue/language/conformance/ConformanceEngine.java
+++ b/src/main/java/blue/language/conformance/ConformanceEngine.java
@@ -10,8 +10,10 @@
import blue.language.utils.limits.Limits;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Objects;
+import java.util.Set;
public final class ConformanceEngine {
@@ -96,4 +98,28 @@ public ConformancePlan planGeneralization(FrozenNode canonicalRoot,
allChangedPaths,
nextCanonical != null);
}
+
+ public boolean isSubtypeOf(String candidateBlueId, String expectedAncestorBlueId) {
+ if (candidateBlueId == null || expectedAncestorBlueId == null) {
+ return false;
+ }
+ String current = candidateBlueId;
+ Set seen = new HashSet<>();
+ while (current != null && seen.add(current)) {
+ if (Objects.equals(current, expectedAncestorBlueId)) {
+ return true;
+ }
+ current = parentTypeBlueId(current);
+ }
+ return false;
+ }
+
+ private String parentTypeBlueId(String blueId) {
+ List candidates = nodeProvider.fetchByBlueId(blueId);
+ if (candidates == null || candidates.isEmpty()) {
+ return null;
+ }
+ Node type = candidates.get(0).getType();
+ return type != null ? type.getBlueId() : null;
+ }
}
diff --git a/src/main/java/blue/language/conformance/FrozenConformancePlanner.java b/src/main/java/blue/language/conformance/FrozenConformancePlanner.java
index d259ec9..32d697c 100644
--- a/src/main/java/blue/language/conformance/FrozenConformancePlanner.java
+++ b/src/main/java/blue/language/conformance/FrozenConformancePlanner.java
@@ -61,7 +61,7 @@ ConformancePlan plan(FrozenNode canonicalRoot, FrozenNode resolvedRoot, String c
if (nextCanonicalRoot != null) {
FrozenNode before = read(nextCanonicalRoot, path);
- FrozenNode after = reuseUnchangedSubtrees(before, canonicalize(generalizedNode.resolved()));
+ FrozenNode after = reuseUnchangedSubtrees(before, canonicalize(generalizedNode.resolved(), nextCanonicalRoot));
nextCanonicalRoot = replaceAt(nextCanonicalRoot, path, after);
canonicalPatches.add(new CanonicalGeneralizationPatch(path, before, after));
}
@@ -79,36 +79,95 @@ private GeneralizedNode generalizeNode(FrozenNode node) {
if (node == null) {
return GeneralizedNode.unchanged(node);
}
+ if (!hasTypeMetadata(node)) {
+ return GeneralizedNode.unchanged(node);
+ }
- ConformanceResult result = check(node);
- FrozenNode current = node;
+ Node canonical = new MergeReverser().reverse(node.toNode());
+ ConformanceResult result = checkCanonical(canonical);
+ FrozenNode type = node.getType();
+ FrozenNode itemType = node.getItemType();
+ FrozenNode keyType = node.getKeyType();
+ FrozenNode valueType = node.getValueType();
List metadataFields = new ArrayList<>();
boolean generalized = false;
while (!result.isConformant()) {
- GeneralizationStep step = nextGeneralizationStep(current);
+ GeneralizationStep step = nextGeneralizationStep(type, itemType, keyType, valueType);
if (step == null) {
throw new IllegalArgumentException("Node cannot be generalized to a conforming type: " + result.getMessage());
}
- current = generalizedNode(current, step);
+ applyGeneralizationStep(canonical, step);
+ switch (step.metadataField()) {
+ case "type":
+ type = step.parentType();
+ break;
+ case "itemType":
+ itemType = step.parentType();
+ break;
+ case "keyType":
+ keyType = step.parentType();
+ break;
+ case "valueType":
+ valueType = step.parentType();
+ break;
+ default:
+ throw new IllegalStateException("Unsupported metadata field for generalization: " + step.metadataField());
+ }
metadataFields.add(step.metadataField());
generalized = true;
- result = check(current);
+ result = checkCanonical(canonical);
}
- return new GeneralizedNode(current, generalized, metadataFields);
+ if (!generalized) {
+ return GeneralizedNode.unchanged(node);
+ }
+ Node resolved = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache)
+ .resolve(canonical, Limits.NO_LIMITS);
+ return new GeneralizedNode(reuseUnchangedSubtrees(node,
+ FrozenNode.fromResolvedNode(resolved, resolvedReferenceCache)), true, metadataFields);
+ }
+
+ private boolean hasTypeMetadata(FrozenNode node) {
+ return node.getType() != null
+ || node.getItemType() != null
+ || node.getKeyType() != null
+ || node.getValueType() != null;
}
private ConformanceResult check(FrozenNode node) {
if (node == null) {
return ConformanceResult.conformant();
}
+ return checkCanonical(new MergeReverser().reverse(node.toNode()));
+ }
+
+ private ConformanceResult checkCanonical(Node canonical) {
try {
- new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache).resolve(node.toNode(), Limits.NO_LIMITS);
+ new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache).resolve(canonical, Limits.NO_LIMITS);
return ConformanceResult.conformant();
} catch (RuntimeException ex) {
return ConformanceResult.nonConformant(ex.getMessage());
}
}
+ private GeneralizationStep nextGeneralizationStep(FrozenNode typeNode,
+ FrozenNode itemTypeNode,
+ FrozenNode keyTypeNode,
+ FrozenNode valueTypeNode) {
+ GeneralizationStep type = generalizationStep("type", typeNode);
+ if (type != null) {
+ return type;
+ }
+ GeneralizationStep itemType = generalizationStep("itemType", itemTypeNode);
+ if (itemType != null) {
+ return itemType;
+ }
+ GeneralizationStep keyType = generalizationStep("keyType", keyTypeNode);
+ if (keyType != null) {
+ return keyType;
+ }
+ return generalizationStep("valueType", valueTypeNode);
+ }
+
private GeneralizationStep nextGeneralizationStep(FrozenNode node) {
GeneralizationStep type = generalizationStep("type", node.getType());
if (type != null) {
@@ -130,28 +189,24 @@ private GeneralizationStep generalizationStep(String metadataField, FrozenNode t
return parentType != null ? new GeneralizationStep(metadataField, parentType) : null;
}
- private FrozenNode generalizedNode(FrozenNode node, GeneralizationStep step) {
- Node canonical = new MergeReverser().reverse(node.toNode());
+ private void applyGeneralizationStep(Node canonical, GeneralizationStep step) {
Node parentType = new Node().blueId(typeReferenceBlueId(step.parentType()));
switch (step.metadataField()) {
case "type":
canonical.type(parentType);
- break;
+ return;
case "itemType":
canonical.itemType(parentType);
- break;
+ return;
case "keyType":
canonical.keyType(parentType);
- break;
+ return;
case "valueType":
canonical.valueType(parentType);
- break;
+ return;
default:
throw new IllegalStateException("Unsupported metadata field for generalization: " + step.metadataField());
}
- Node resolved = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache)
- .resolve(canonical, Limits.NO_LIMITS);
- return reuseUnchangedSubtrees(node, FrozenNode.fromResolvedNode(resolved, resolvedReferenceCache));
}
private FrozenNode parentType(FrozenNode type) {
@@ -174,8 +229,12 @@ private String typeReferenceBlueId(FrozenNode type) {
: BlueIdCalculator.calculateBlueId(new MergeReverser().reverse(type.toNode()));
}
- private FrozenNode canonicalize(FrozenNode resolvedNode) {
- return FrozenNode.fromNode(new MergeReverser().reverse(resolvedNode.toNode()));
+ private FrozenNode canonicalize(FrozenNode resolvedNode, FrozenNode canonicalRoot) {
+ Node canonical = new MergeReverser().reverse(resolvedNode.toNode());
+ if (canonicalRoot != null && !canonicalRoot.isStrictBlueIdValidation()) {
+ return FrozenNode.fromUncheckedCanonicalNode(canonical);
+ }
+ return FrozenNode.fromNode(canonical);
}
private List existingPathSegments(FrozenNode root, String pointer) {
diff --git a/src/main/java/blue/language/dictionary/DictionaryAwareExporter.java b/src/main/java/blue/language/dictionary/DictionaryAwareExporter.java
index d2b1cfe..6182d3e 100644
--- a/src/main/java/blue/language/dictionary/DictionaryAwareExporter.java
+++ b/src/main/java/blue/language/dictionary/DictionaryAwareExporter.java
@@ -54,6 +54,7 @@ private Node transformNode(Node node, Set inliningStack) {
result.itemType(transformTypeReference(result.getItemType(), inliningStack));
result.keyType(transformTypeReference(result.getKeyType(), inliningStack));
result.valueType(transformTypeReference(result.getValueType(), inliningStack));
+ result.contracts(transformNode(result.getContracts(), inliningStack));
if (result.getItems() != null) {
List transformedItems = new ArrayList<>(result.getItems().size());
@@ -137,7 +138,6 @@ private Node inlineDefinition(TypeDictionary dictionary, String currentBlueId, S
private Schema transformSchema(Schema schema, Set inliningStack) {
Schema result = schema.clone();
result.required(transformNode(result.getRequired(), inliningStack));
- result.allowMultiple(transformNode(result.getAllowMultiple(), inliningStack));
result.minLength(transformNode(result.getMinLength(), inliningStack));
result.maxLength(transformNode(result.getMaxLength(), inliningStack));
result.minimum(transformNode(result.getMinimum(), inliningStack));
diff --git a/src/main/java/blue/language/mapping/ComplexObjectConverter.java b/src/main/java/blue/language/mapping/ComplexObjectConverter.java
index 9606940..ae55bf0 100644
--- a/src/main/java/blue/language/mapping/ComplexObjectConverter.java
+++ b/src/main/java/blue/language/mapping/ComplexObjectConverter.java
@@ -77,7 +77,7 @@ private void convertFields(Node node, Class> clazz, Object instance) throws Il
} else if (field.isAnnotationPresent(BlueDescription.class)) {
fieldValue = handleBlueDescriptionAnnotation(node, clazz, field);
} else {
- Node fieldNode = node.getProperties() != null ? node.getProperties().get(propertyName) : null;
+ Node fieldNode = propertyNode(node, propertyName);
if (fieldNode != null) {
if (Nodes.isEmptyNode(fieldNode)) {
@@ -116,27 +116,34 @@ private void convertFields(Node node, Class> clazz, Object instance) throws Il
}
private String handleBlueIdAnnotation(Node node, String propertyName) {
- Node targetNode = node.getProperties() != null ? node.getProperties().get(propertyName) : null;
+ Node targetNode = propertyNode(node, propertyName);
if (targetNode == null) {
return null;
}
- return BlueIdCalculator.calculateBlueId(targetNode);
+ return BlueIdCalculator.calculateUncheckedBlueId(targetNode);
}
private String handleBlueNameAnnotation(Node node, Class> clazz, Field field) {
BlueName annotation = field.getAnnotation(BlueName.class);
String propertyName = JacksonPropertyNames.resolveTargetPropertyName(clazz, annotation.value());
- Node targetNode = node.getProperties() != null ? node.getProperties().get(propertyName) : null;
+ Node targetNode = propertyNode(node, propertyName);
return targetNode != null ? targetNode.getName() : null;
}
private String handleBlueDescriptionAnnotation(Node node, Class> clazz, Field field) {
BlueDescription annotation = field.getAnnotation(BlueDescription.class);
String propertyName = JacksonPropertyNames.resolveTargetPropertyName(clazz, annotation.value());
- Node targetNode = node.getProperties() != null ? node.getProperties().get(propertyName) : null;
+ Node targetNode = propertyNode(node, propertyName);
return targetNode != null ? targetNode.getDescription() : null;
}
+ private Node propertyNode(Node node, String propertyName) {
+ if ("contracts".equals(propertyName)) {
+ return node.getContracts();
+ }
+ return node.getProperties() != null ? node.getProperties().get(propertyName) : null;
+ }
+
private Class> getRawType(Type type) {
if (type instanceof Class>) {
return (Class>) type;
diff --git a/src/main/java/blue/language/merge/Merger.java b/src/main/java/blue/language/merge/Merger.java
index 7040434..112a984 100644
--- a/src/main/java/blue/language/merge/Merger.java
+++ b/src/main/java/blue/language/merge/Merger.java
@@ -11,13 +11,14 @@
import java.util.ArrayList;
import java.util.HashSet;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static blue.language.utils.Properties.LIST_MERGE_POLICY_APPEND_ONLY;
import static blue.language.utils.Properties.LIST_MERGE_POLICY_POSITIONAL;
+import static blue.language.utils.Properties.LIST_CONTROL_REPLACE;
import static blue.language.utils.Properties.LIST_TYPE;
import static blue.language.utils.Properties.LIST_TYPE_BLUE_ID;
import static blue.language.utils.Properties.CORE_TYPE_BLUE_IDS;
@@ -110,6 +111,15 @@ private void mergeObject(Node target, Node source, Limits limits) {
mergeChildren(target, children, limits);
}
+ if (source.getContracts() != null && limits.shouldMergePathSegment("contracts", source.getContracts())) {
+ limits.enterPathSegment("contracts", source.getContracts());
+ try {
+ mergeContracts(target, source.getContracts(), limits);
+ } finally {
+ limits.exitPathSegment();
+ }
+ }
+
Map properties = source.getProperties();
if (properties != null) {
properties.forEach((key, value) -> {
@@ -229,7 +239,7 @@ private void mergePositionalChildren(List targetChildren, List sourc
appendChildren(targetChildren, sourceChildren, start, limits, itemType);
return;
}
- mergeLegacyPositionalChildren(targetChildren, sourceChildren, start, limits, itemType);
+ mergePlainPositionalChildren(targetChildren, sourceChildren, start, limits, itemType);
return;
}
@@ -254,7 +264,7 @@ private void mergePositionalChildren(List targetChildren, List sourc
}
}
- private void mergeLegacyPositionalChildren(List targetChildren, List sourceChildren, int start, Limits limits, Node itemType) {
+ private void mergePlainPositionalChildren(List targetChildren, List sourceChildren, int start, Limits limits, Node itemType) {
int sourceLength = sourceChildren.size() - start;
if (sourceLength < targetChildren.size()) {
throw new IllegalArgumentException(String.format(
@@ -280,6 +290,14 @@ private void mergeOrReplacePosition(List targetChildren, int position, Nod
Node effectiveItemType = targetChildren.get(position).getType() != null
? targetChildren.get(position).getType()
: itemType;
+ if (hasReplacement(overlay)) {
+ Node replacement = overlay.getProperties().get(LIST_CONTROL_REPLACE);
+ Node resolvedChild = resolveListChild(replacement, limits, String.valueOf(position), effectiveItemType);
+ if (resolvedChild != null) {
+ targetChildren.set(position, resolvedChild);
+ }
+ return;
+ }
if (isEmptyPlaceholder(targetChildren.get(position)) || overlay.getValue() != null || overlay.getItems() != null) {
Node resolvedChild = resolveListChild(overlay, limits, String.valueOf(position), effectiveItemType);
if (resolvedChild != null) {
@@ -294,9 +312,23 @@ private void mergeOrReplacePosition(List targetChildren, int position, Nod
}
return;
}
+ if (isObjectOverlay(overlay) && !isObjectCompatibleListItem(targetChildren.get(position))) {
+ throw new IllegalArgumentException("\"$pos\" object overlays require an object-compatible inherited list item.");
+ }
merge(targetChildren.get(position), overlay, limits);
}
+ private boolean isObjectOverlay(Node overlay) {
+ return overlay.getProperties() != null && !overlay.getProperties().isEmpty();
+ }
+
+ private boolean isObjectCompatibleListItem(Node inherited) {
+ return inherited != null
+ && inherited.getValue() == null
+ && inherited.getItems() == null
+ && inherited.getBlueId() == null;
+ }
+
private void appendChildren(List targetChildren, List sourceChildren, int start, Limits limits, Node itemType) {
for (int i = start; i < sourceChildren.size(); i++) {
Node resolvedChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(targetChildren.size()), itemType);
@@ -432,13 +464,43 @@ private void validateListControls(List sourceChildren, String mergePolicy)
if (!positions.add(child.getPosition())) {
throw new IllegalArgumentException("Duplicate \"$pos\" value in list: " + child.getPosition());
}
+ } else if (hasReplacement(child)) {
+ throw new IllegalArgumentException("\"$replace\" is valid only inside a \"$pos\" list overlay.");
+ }
+ if (hasReplacement(child)) {
+ validateReplacementOverlay(child);
}
}
}
+ private boolean hasReplacement(Node node) {
+ return node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_REPLACE);
+ }
+
+ private void validateReplacementOverlay(Node node) {
+ boolean onlyReplaceProperty = node.getProperties() != null
+ && node.getProperties().size() == 1
+ && node.getProperties().containsKey(LIST_CONTROL_REPLACE);
+ if (!onlyReplaceProperty
+ || node.getValue() != null
+ || node.getItems() != null
+ || node.getType() != null
+ || node.getItemType() != null
+ || node.getKeyType() != null
+ || node.getValueType() != null
+ || node.getSchema() != null
+ || node.getMergePolicy() != null
+ || node.getBlueId() != null
+ || node.getPreviousBlueId() != null
+ || node.getName() != null
+ || node.getDescription() != null) {
+ throw new IllegalArgumentException("\"$replace\" cannot be combined with sibling overlay fields other than \"$pos\".");
+ }
+ }
+
private void mergeProperty(Node target, String sourceKey, Node sourceValue, Limits limits) {
if (target.getProperties() == null)
- target.properties(new HashMap<>());
+ target.properties(new LinkedHashMap<>());
Node targetValue = target.getProperties().get(sourceKey);
if (targetValue == null) {
Node node = resolve(sourceValue, limits);
@@ -451,6 +513,15 @@ private void mergeProperty(Node target, String sourceKey, Node sourceValue, Limi
}
}
+ private void mergeContracts(Node target, Node sourceContracts, Limits limits) {
+ if (target.getContracts() == null) {
+ target.contracts(resolve(sourceContracts, limits));
+ return;
+ }
+ Node resolved = resolve(sourceContracts, limits);
+ mergeObject(target.getContracts(), resolved, limits);
+ }
+
private boolean hasListControls(Node node) {
List items = node.getItems();
return items != null && items.stream()
diff --git a/src/main/java/blue/language/merge/processor/SchemaPropagator.java b/src/main/java/blue/language/merge/processor/SchemaPropagator.java
index 5f46ab4..9c784e0 100644
--- a/src/main/java/blue/language/merge/processor/SchemaPropagator.java
+++ b/src/main/java/blue/language/merge/processor/SchemaPropagator.java
@@ -7,9 +7,12 @@
import blue.language.model.Node;
import blue.language.utils.BlueIdCalculator;
import blue.language.utils.LeastCommonMultiple;
+import blue.language.utils.NodeToBlueIdInput;
+import blue.language.utils.UncheckedObjectMapper;
import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -33,7 +36,6 @@ public void process(Node target, Node source, NodeProvider nodeProvider, NodeRes
}
propagateRequired(sourceSchema, targetSchema);
- propagateAllowMultiple(sourceSchema, targetSchema);
propagateMinLength(sourceSchema, targetSchema);
propagateMaxLength(sourceSchema, targetSchema);
propagateMinimum(sourceSchema, targetSchema);
@@ -51,11 +53,11 @@ public void process(Node target, Node source, NodeProvider nodeProvider, NodeRes
private void propagateMinLength(Schema source, Schema target) {
- propagateMinValue(source.getMinLengthValue(), target::getMinLengthValue, target::minLength);
+ propagateMinValue(source.getMinLengthExact(), target::getMinLengthExact, target::minLength);
}
private void propagateMaxLength(Schema source, Schema target) {
- propagateMaxValue(source.getMaxLengthValue(), target::getMaxLengthValue, target::maxLength);
+ propagateMaxValue(source.getMaxLengthExact(), target::getMaxLengthExact, target::maxLength);
}
private void propagateMinimum(Schema source, Schema target) {
@@ -78,10 +80,6 @@ private void propagateRequired(Schema source, Schema target) {
propagateBoolean(source.getRequiredValue(), target::getRequiredValue, target::required, true);
}
- private void propagateAllowMultiple(Schema source, Schema target) {
- propagateBoolean(source.getAllowMultipleValue(), target::getAllowMultipleValue, target::allowMultiple, true);
- }
-
private > void propagateMinValue(T sourceValue,
Supplier targetValueGetter, Consumer targetValueSetter) {
if (sourceValue != null) {
@@ -123,11 +121,11 @@ private void propagateMultipleOf(Schema source, Schema target) {
}
private void propagateMinItems(Schema source, Schema target) {
- propagateMinValue(source.getMinItemsValue(), target::getMinItemsValue, target::minItems);
+ propagateMinValue(source.getMinItemsExact(), target::getMinItemsExact, target::minItems);
}
private void propagateMaxItems(Schema source, Schema target) {
- propagateMaxValue(source.getMaxItemsValue(), target::getMaxItemsValue, target::maxItems);
+ propagateMaxValue(source.getMaxItemsExact(), target::getMaxItemsExact, target::maxItems);
}
private void propagateUniqueItems(Schema source, Schema target) {
@@ -135,11 +133,11 @@ private void propagateUniqueItems(Schema source, Schema target) {
}
private void propagateMinFields(Schema source, Schema target) {
- propagateMinValue(source.getMinFieldsValue(), target::getMinFieldsValue, target::minFields);
+ propagateMinValue(source.getMinFieldsExact(), target::getMinFieldsExact, target::minFields);
}
private void propagateMaxFields(Schema source, Schema target) {
- propagateMaxValue(source.getMaxFieldsValue(), target::getMaxFieldsValue, target::maxFields);
+ propagateMaxValue(source.getMaxFieldsExact(), target::getMaxFieldsExact, target::maxFields);
}
private void propagateEnum(Schema source, Schema target) {
@@ -150,7 +148,7 @@ private void propagateEnum(Schema source, Schema target) {
List targetEnum = target.getEnum();
if (targetEnum == null) {
- target.enumValues(cloneNodes(sourceEnum));
+ target.enumValues(canonicalizeEnum(sourceEnum));
return;
}
@@ -163,7 +161,7 @@ private void propagateEnum(Schema source, Schema target) {
intersection.add(targetValue.clone());
}
}
- target.enumValues(intersection);
+ target.enumValues(canonicalizeEnum(intersection));
}
private List cloneNodes(List nodes) {
@@ -178,4 +176,20 @@ private String enumComparableBlueId(Node node) {
return BlueIdCalculator.calculateBlueId(comparable);
}
+ private List canonicalizeEnum(List nodes) {
+ Map uniqueByIdentity = new LinkedHashMap<>();
+ for (Node node : nodes) {
+ uniqueByIdentity.putIfAbsent(enumComparableBlueId(node), node.clone());
+ }
+ List result = new ArrayList<>(uniqueByIdentity.values());
+ result.sort((left, right) -> enumCanonicalKey(left).compareTo(enumCanonicalKey(right)));
+ return result;
+ }
+
+ private String enumCanonicalKey(Node node) {
+ Node comparable = node.clone();
+ comparable.schema(null);
+ return UncheckedObjectMapper.JSON_MAPPER.writeValueAsString(NodeToBlueIdInput.get(comparable));
+ }
+
}
diff --git a/src/main/java/blue/language/merge/processor/SchemaVerifier.java b/src/main/java/blue/language/merge/processor/SchemaVerifier.java
index f9d75df..8b9e1af 100644
--- a/src/main/java/blue/language/merge/processor/SchemaVerifier.java
+++ b/src/main/java/blue/language/merge/processor/SchemaVerifier.java
@@ -6,9 +6,11 @@
import blue.language.model.Schema;
import blue.language.model.Node;
import blue.language.utils.BlueIdCalculator;
+import blue.language.utils.BlueNumbers;
import blue.language.utils.NodeToMapListOrValue;
import java.math.BigDecimal;
+import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -32,48 +34,47 @@ public void postProcess(Node target, Node source, NodeProvider nodeProvider, Nod
verifyWellFormed(schema);
verifyRequired(schema.getRequiredValue(), target);
- verifyAllowMultiple(schema.getAllowMultipleValue(), target.getItems());
- verifyMinLength(schema.getMinLengthValue(), target.getValue());
- verifyMaxLength(schema.getMaxLengthValue(), target.getValue());
- verifyMinimum(schema.getMinimumValue(), target.getValue());
- verifyMaximum(schema.getMaximumValue(), target.getValue());
- verifyExclusiveMinimum(schema.getExclusiveMinimumValue(), target.getValue());
- verifyExclusiveMaximum(schema.getExclusiveMaximumValue(), target.getValue());
- verifyMultipleOf(schema.getMultipleOfValue(), target.getValue());
- verifyMinItems(schema.getMinItemsValue(), target.getItems());
- verifyMaxItems(schema.getMaxItemsValue(), target.getItems());
- verifyUniqueItems(schema.getUniqueItemsValue(), target.getItems());
- verifyMinFields(schema.getMinFieldsValue(), target.getProperties());
- verifyMaxFields(schema.getMaxFieldsValue(), target.getProperties());
+ verifyMinLength(schema.getMinLengthExact(), target);
+ verifyMaxLength(schema.getMaxLengthExact(), target);
+ verifyMinimum(schema.getMinimumValue(), target);
+ verifyMaximum(schema.getMaximumValue(), target);
+ verifyExclusiveMinimum(schema.getExclusiveMinimumValue(), target);
+ verifyExclusiveMaximum(schema.getExclusiveMaximumValue(), target);
+ verifyMultipleOf(schema.getMultipleOfValue(), target);
+ verifyMinItems(schema.getMinItemsExact(), target);
+ verifyMaxItems(schema.getMaxItemsExact(), target);
+ verifyUniqueItems(schema.getUniqueItemsValue(), target);
+ verifyMinFields(schema.getMinFieldsExact(), target);
+ verifyMaxFields(schema.getMaxFieldsExact(), target);
verifyEnum(schema.getEnum(), target);
}
private void verifyWellFormed(Schema schema) {
- verifyNonNegative("minLength", schema.getMinLengthValue());
- verifyNonNegative("maxLength", schema.getMaxLengthValue());
- verifyMinLessThanOrEqualMax("minLength", schema.getMinLengthValue(), "maxLength", schema.getMaxLengthValue());
+ verifyNonNegative("minLength", schema.getMinLengthExact());
+ verifyNonNegative("maxLength", schema.getMaxLengthExact());
+ verifyMinLessThanOrEqualMax("minLength", schema.getMinLengthExact(), "maxLength", schema.getMaxLengthExact());
- verifyNonNegative("minItems", schema.getMinItemsValue());
- verifyNonNegative("maxItems", schema.getMaxItemsValue());
- verifyMinLessThanOrEqualMax("minItems", schema.getMinItemsValue(), "maxItems", schema.getMaxItemsValue());
+ verifyNonNegative("minItems", schema.getMinItemsExact());
+ verifyNonNegative("maxItems", schema.getMaxItemsExact());
+ verifyMinLessThanOrEqualMax("minItems", schema.getMinItemsExact(), "maxItems", schema.getMaxItemsExact());
- verifyNonNegative("minFields", schema.getMinFieldsValue());
- verifyNonNegative("maxFields", schema.getMaxFieldsValue());
- verifyMinLessThanOrEqualMax("minFields", schema.getMinFieldsValue(), "maxFields", schema.getMaxFieldsValue());
+ verifyNonNegative("minFields", schema.getMinFieldsExact());
+ verifyNonNegative("maxFields", schema.getMaxFieldsExact());
+ verifyMinLessThanOrEqualMax("minFields", schema.getMinFieldsExact(), "maxFields", schema.getMaxFieldsExact());
verifyMinimumLessThanOrEqualMaximum(schema.getMinimumValue(), schema.getMaximumValue());
verifyExclusiveMinimumLessThanExclusiveMaximum(schema.getExclusiveMinimumValue(), schema.getExclusiveMaximumValue());
verifyMultipleOfKeyword(schema.getMultipleOfValue());
}
- private void verifyNonNegative(String keyword, Integer value) {
- if (value != null && value < 0) {
+ private void verifyNonNegative(String keyword, BigInteger value) {
+ if (value != null && value.signum() < 0) {
throw new IllegalArgumentException("Schema keyword \"" + keyword + "\" must be non-negative.");
}
}
- private void verifyMinLessThanOrEqualMax(String minKeyword, Integer minValue, String maxKeyword, Integer maxValue) {
- if (minValue != null && maxValue != null && minValue > maxValue) {
+ private void verifyMinLessThanOrEqualMax(String minKeyword, BigInteger minValue, String maxKeyword, BigInteger maxValue) {
+ if (minValue != null && maxValue != null && minValue.compareTo(maxValue) > 0) {
throw new IllegalArgumentException("Schema keyword \"" + minKeyword + "\" must be less than or equal to \"" + maxKeyword + "\".");
}
}
@@ -107,18 +108,28 @@ private boolean hasPayload(Node node) {
|| (node.getProperties() != null && !node.getProperties().isEmpty());
}
- private void verifyAllowMultiple(Boolean allowMultiple, List items) {
- if ((allowMultiple == null || Boolean.FALSE.equals(allowMultiple)) && items != null && items.size() > 1)
- throw new IllegalArgumentException("Multiple items are not allowed. Found items: " + items);
- }
-
- private void verifyMinLength(Integer minLength, Object value) {
- if (minLength != null && value instanceof String && codePointLength((String) value) < minLength)
+ private void verifyMinLength(BigInteger minLength, Node node) {
+ if (minLength == null) {
+ return;
+ }
+ Object value = requireScalarPayload("minLength", node, String.class, "Text scalar");
+ if (value == null) {
+ return;
+ }
+ if (BigInteger.valueOf(codePointLength((String) value)).compareTo(minLength) < 0) {
throw new IllegalArgumentException("Value \"" + value + "\" is shorter than the minimum length of " + minLength + ".");
+ }
}
- private void verifyMaxLength(Integer maxLength, Object value) {
- if (maxLength != null && value instanceof String && codePointLength((String) value) > maxLength) {
+ private void verifyMaxLength(BigInteger maxLength, Node node) {
+ if (maxLength == null) {
+ return;
+ }
+ Object value = requireScalarPayload("maxLength", node, String.class, "Text scalar");
+ if (value == null) {
+ return;
+ }
+ if (BigInteger.valueOf(codePointLength((String) value)).compareTo(maxLength) > 0) {
throw new IllegalArgumentException("Value \"" + value + "\" is longer than the maximum length of " + maxLength + ".");
}
}
@@ -127,66 +138,105 @@ private int codePointLength(String value) {
return value.codePointCount(0, value.length());
}
- private void verifyMinimum(BigDecimal minimum, Object value) {
- if (minimum != null && value instanceof Number) {
- BigDecimal valueDecimal = new BigDecimal(value.toString());
- if (valueDecimal.compareTo(minimum) < 0) {
- throw new IllegalArgumentException("Value " + value + " is less than the minimum value of " + minimum + ".");
- }
+ private void verifyMinimum(BigDecimal minimum, Node node) {
+ if (minimum == null) {
+ return;
+ }
+ Object value = requireScalarPayload("minimum", node, Number.class, "numeric scalar");
+ if (value == null) {
+ return;
+ }
+ BigDecimal valueDecimal = new BigDecimal(value.toString());
+ if (valueDecimal.compareTo(minimum) < 0) {
+ throw new IllegalArgumentException("Value " + value + " is less than the minimum value of " + minimum + ".");
}
}
- private void verifyMaximum(BigDecimal maximum, Object value) {
- if (maximum != null && value instanceof Number) {
- BigDecimal valueDecimal = new BigDecimal(value.toString());
- if (valueDecimal.compareTo(maximum) > 0) {
- throw new IllegalArgumentException("Value " + value + " is greater than the maximum value of " + maximum + ".");
- }
+ private void verifyMaximum(BigDecimal maximum, Node node) {
+ if (maximum == null) {
+ return;
+ }
+ Object value = requireScalarPayload("maximum", node, Number.class, "numeric scalar");
+ if (value == null) {
+ return;
+ }
+ BigDecimal valueDecimal = new BigDecimal(value.toString());
+ if (valueDecimal.compareTo(maximum) > 0) {
+ throw new IllegalArgumentException("Value " + value + " is greater than the maximum value of " + maximum + ".");
}
}
- private void verifyExclusiveMinimum(BigDecimal exclusiveMinimum, Object value) {
- if (exclusiveMinimum != null && value instanceof Number) {
- BigDecimal valueDecimal = new BigDecimal(value.toString());
- if (valueDecimal.compareTo(exclusiveMinimum) <= 0) {
- throw new IllegalArgumentException("Value " + value + " is less than or equal to the exclusive minimum value of " + exclusiveMinimum + ".");
- }
+ private void verifyExclusiveMinimum(BigDecimal exclusiveMinimum, Node node) {
+ if (exclusiveMinimum == null) {
+ return;
+ }
+ Object value = requireScalarPayload("exclusiveMinimum", node, Number.class, "numeric scalar");
+ if (value == null) {
+ return;
+ }
+ BigDecimal valueDecimal = new BigDecimal(value.toString());
+ if (valueDecimal.compareTo(exclusiveMinimum) <= 0) {
+ throw new IllegalArgumentException("Value " + value + " is less than or equal to the exclusive minimum value of " + exclusiveMinimum + ".");
}
}
- private void verifyExclusiveMaximum(BigDecimal exclusiveMaximum, Object value) {
- if (exclusiveMaximum != null && value instanceof Number) {
- BigDecimal valueDecimal = new BigDecimal(value.toString());
- if (valueDecimal.compareTo(exclusiveMaximum) >= 0) {
- throw new IllegalArgumentException("Value " + value + " is greater than or equal to the exclusive maximum value of " + exclusiveMaximum + ".");
- }
+ private void verifyExclusiveMaximum(BigDecimal exclusiveMaximum, Node node) {
+ if (exclusiveMaximum == null) {
+ return;
+ }
+ Object value = requireScalarPayload("exclusiveMaximum", node, Number.class, "numeric scalar");
+ if (value == null) {
+ return;
+ }
+ BigDecimal valueDecimal = new BigDecimal(value.toString());
+ if (valueDecimal.compareTo(exclusiveMaximum) >= 0) {
+ throw new IllegalArgumentException("Value " + value + " is greater than or equal to the exclusive maximum value of " + exclusiveMaximum + ".");
}
}
- private void verifyMultipleOf(BigDecimal multipleOf, Object value) {
- if (multipleOf != null && value instanceof Number) {
- BigDecimal valueDecimal = new BigDecimal(value.toString());
- BigDecimal remainder = valueDecimal.remainder(multipleOf);
- if (remainder.compareTo(BigDecimal.ZERO) != 0) {
- throw new IllegalArgumentException("Value " + value + " is not a multiple of " + multipleOf + ".");
- }
+ private void verifyMultipleOf(BigDecimal multipleOf, Node node) {
+ if (multipleOf == null) {
+ return;
+ }
+ Object value = requireScalarPayload("multipleOf", node, Number.class, "numeric scalar");
+ if (value == null) {
+ return;
+ }
+ if (!BlueNumbers.isExactBinary64Multiple(value, multipleOf)) {
+ throw new IllegalArgumentException("Value " + value + " is not a multiple of " + multipleOf + ".");
}
}
- private void verifyMinItems(Integer minItems, List items) {
- if (minItems != null && (items == null || items.size() < minItems)) {
+ private void verifyMinItems(BigInteger minItems, Node node) {
+ if (minItems == null) {
+ return;
+ }
+ requireListPayload("minItems", node);
+ List items = node.getItems();
+ int size = items != null ? items.size() : 0;
+ if (BigInteger.valueOf(size).compareTo(minItems) < 0) {
throw new IllegalArgumentException("Number of items " + (items != null ? items.size() : 0) + " is less than the minimum required items of " + minItems + ".");
}
}
- private void verifyMaxItems(Integer maxItems, List items) {
- if (maxItems != null && items != null && items.size() > maxItems) {
+ private void verifyMaxItems(BigInteger maxItems, Node node) {
+ if (maxItems == null) {
+ return;
+ }
+ requireListPayload("maxItems", node);
+ List items = node.getItems();
+ if (items != null && BigInteger.valueOf(items.size()).compareTo(maxItems) > 0) {
throw new IllegalArgumentException("Number of items " + items.size() + " is greater than the maximum allowed items of " + maxItems + ".");
}
}
- private void verifyUniqueItems(Boolean uniqueItems, List items) {
- if (Boolean.TRUE.equals(uniqueItems) && items != null) {
+ private void verifyUniqueItems(Boolean uniqueItems, Node node) {
+ if (!Boolean.TRUE.equals(uniqueItems)) {
+ return;
+ }
+ requireListPayload("uniqueItems", node);
+ List items = node.getItems();
+ if (items != null) {
int uniqueItemsCount = items.stream()
.map(NodeToMapListOrValue::get)
.map(doc -> YAML_MAPPER.convertValue(doc, Node.class))
@@ -198,16 +248,26 @@ private void verifyUniqueItems(Boolean uniqueItems, List items) {
}
}
- private void verifyMinFields(Integer minFields, Map properties) {
+ private void verifyMinFields(BigInteger minFields, Node node) {
+ if (minFields == null) {
+ return;
+ }
+ requireObjectPayload("minFields", node);
+ Map properties = node.getProperties();
int fieldCount = properties == null ? 0 : properties.size();
- if (minFields != null && fieldCount < minFields) {
+ if (BigInteger.valueOf(fieldCount).compareTo(minFields) < 0) {
throw new IllegalArgumentException("Number of fields " + fieldCount + " is less than the minimum required fields of " + minFields + ".");
}
}
- private void verifyMaxFields(Integer maxFields, Map properties) {
+ private void verifyMaxFields(BigInteger maxFields, Node node) {
+ if (maxFields == null) {
+ return;
+ }
+ requireObjectPayload("maxFields", node);
+ Map properties = node.getProperties();
int fieldCount = properties == null ? 0 : properties.size();
- if (maxFields != null && fieldCount > maxFields) {
+ if (BigInteger.valueOf(fieldCount).compareTo(maxFields) > 0) {
throw new IllegalArgumentException("Number of fields " + fieldCount + " is greater than the maximum allowed fields of " + maxFields + ".");
}
}
@@ -216,6 +276,12 @@ private void verifyEnum(List enumValues, Node node) {
if (enumValues == null) {
return;
}
+ if (node.getValue() == null) {
+ if (hasPayload(node)) {
+ throw wrongKind("enum", "scalar", node);
+ }
+ return;
+ }
String nodeBlueId = comparableBlueId(node);
boolean matched = enumValues.stream()
@@ -231,4 +297,31 @@ private String comparableBlueId(Node node) {
comparable.schema(null);
return BlueIdCalculator.calculateBlueId(comparable);
}
+
+ private Object requireScalarPayload(String keyword, Node node, Class> expectedClass, String expected) {
+ Object value = node.getValue();
+ if (value == null && !hasPayload(node)) {
+ return null;
+ }
+ if (!expectedClass.isInstance(value)) {
+ throw wrongKind(keyword, expected, node);
+ }
+ return value;
+ }
+
+ private void requireListPayload(String keyword, Node node) {
+ if (node.getValue() != null || (node.getProperties() != null && !node.getProperties().isEmpty())) {
+ throw wrongKind(keyword, "List payload", node);
+ }
+ }
+
+ private void requireObjectPayload(String keyword, Node node) {
+ if (node.getValue() != null || node.getItems() != null) {
+ throw wrongKind(keyword, "Dictionary/object payload", node);
+ }
+ }
+
+ private IllegalArgumentException wrongKind(String keyword, String expected, Node node) {
+ return new IllegalArgumentException("Schema keyword \"" + keyword + "\" applies to wrong kind; expected " + expected + ".");
+ }
}
diff --git a/src/main/java/blue/language/model/Node.java b/src/main/java/blue/language/model/Node.java
index d5a539c..cd58c6a 100644
--- a/src/main/java/blue/language/model/Node.java
+++ b/src/main/java/blue/language/model/Node.java
@@ -26,6 +26,7 @@ public class Node implements Cloneable {
private Object value;
private List items;
private Map properties;
+ private Node contracts;
private String blueId;
private Schema schema;
private String mergePolicy;
@@ -66,7 +67,13 @@ public Object getValue() {
} else if (DOUBLE_TYPE_BLUE_ID.equals(typeBlueId)) {
return BlueNumbers.toCanonicalDoubleValue(this.value);
} else if (BOOLEAN_TYPE_BLUE_ID.equals(typeBlueId) && this.value instanceof String) {
- return Boolean.parseBoolean((String) this.value);
+ if ("true".equals(this.value)) {
+ return true;
+ }
+ if ("false".equals(this.value)) {
+ return false;
+ }
+ throw new IllegalArgumentException("Explicit Boolean scalar values must be \"true\" or \"false\".");
}
}
return value;
@@ -84,6 +91,10 @@ public Map getProperties() {
return properties;
}
+ public Node getContracts() {
+ return contracts;
+ }
+
public String getBlueId() {
return blueId;
}
@@ -99,6 +110,7 @@ public boolean isReferenceOnly() {
&& value == null
&& items == null
&& properties == null
+ && contracts == null
&& schema == null
&& mergePolicy == null
&& previousBlueId == null
@@ -212,17 +224,24 @@ public Node items(Node... items) {
}
public Node properties(Map properties) {
- if (properties != null) {
- this.properties = new HashMap<>(properties);
- } else {
- this.properties = null;
+ this.properties = null;
+ if (properties == null) {
+ return this;
+ }
+ Map objectProperties = new LinkedHashMap<>(properties);
+ if (objectProperties.containsKey(OBJECT_CONTRACTS)) {
+ this.contracts = objectProperties.remove(OBJECT_CONTRACTS);
}
+ this.properties = objectProperties;
return this;
}
public Node properties(String key1, Node value1) {
+ if (OBJECT_CONTRACTS.equals(key1)) {
+ return contracts(value1);
+ }
if (this.properties == null) {
- this.properties = new HashMap<>();
+ this.properties = new LinkedHashMap<>();
}
this.properties.put(key1, value1);
return this;
@@ -251,6 +270,11 @@ public Node blueId(String blueId) {
return this;
}
+ public Node contracts(Node contracts) {
+ this.contracts = contracts;
+ return this;
+ }
+
public Node schema(Schema schema) {
this.schema = schema;
return this;
@@ -294,6 +318,7 @@ public Node replaceWith(Node source) {
this.previousBlueId = source.previousBlueId;
this.position = source.position;
this.inlineValue = source.inlineValue;
+ this.contracts = source.contracts != null ? source.contracts.clone() : null;
this.type = source.type != null ? source.type.clone() : null;
this.itemType = source.itemType != null ? source.itemType.clone() : null;
@@ -308,7 +333,7 @@ public Node replaceWith(Node source) {
Map.Entry::getKey,
entry -> entry.getValue().clone(),
(e1, e2) -> e1,
- HashMap::new
+ LinkedHashMap::new
))
: null;
this.schema = source.schema != null ? source.schema.clone() : null;
@@ -375,6 +400,7 @@ public String toString() {
", value=" + value +
", items=" + items +
", properties=" + properties +
+ ", contracts=" + contracts +
", blueId='" + blueId + '\'' +
", schema=" + schema +
", mergePolicy='" + mergePolicy + '\'' +
diff --git a/src/main/java/blue/language/model/NodeDeserializer.java b/src/main/java/blue/language/model/NodeDeserializer.java
index ef720c3..241a6ac 100644
--- a/src/main/java/blue/language/model/NodeDeserializer.java
+++ b/src/main/java/blue/language/model/NodeDeserializer.java
@@ -1,6 +1,7 @@
package blue.language.model;
import blue.language.utils.UncheckedObjectMapper;
+import blue.language.utils.BlueNumbers;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
@@ -18,6 +19,23 @@
public class NodeDeserializer extends StdDeserializer {
+ private static final Set ALLOWED_SCHEMA_KEYS = new HashSet<>(Arrays.asList(
+ "required",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "exclusiveMinimum",
+ "exclusiveMaximum",
+ "multipleOf",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minFields",
+ "maxFields",
+ "enum"
+ ));
+
protected NodeDeserializer() {
super(Node.class);
}
@@ -25,10 +43,16 @@ protected NodeDeserializer() {
@Override
public Node deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode treeNode = p.readValueAsTree();
- return handleNode(treeNode);
+ return handleNode(treeNode, "/", true);
}
- private Node handleNode(JsonNode node) {
+ private Node handleNode(JsonNode node, String path, boolean root) {
+ if (node == null || node.isNull()) {
+ if (root) {
+ throw new IllegalArgumentException("Root null is not a valid Blue document.");
+ }
+ return new Node().value(null).inlineValue(true);
+ }
if (node.isObject()) {
Node obj = new Node();
Map properties = new LinkedHashMap<>();
@@ -42,27 +66,35 @@ private Node handleNode(JsonNode node) {
JsonNode value = entry.getValue();
switch (key) {
case OBJECT_NAME:
- obj.name(value.isNull() ? null : value.asText());
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.name(requireString(value, key, appendPath(path, key)));
break;
case OBJECT_DESCRIPTION:
- obj.description(value.isNull() ? null : value.asText());
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.description(requireString(value, key, appendPath(path, key)));
break;
case OBJECT_TYPE:
- obj.type(handleNode(value));
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.type(handleNode(value, appendPath(path, key), false));
break;
case OBJECT_ITEM_TYPE:
- obj.itemType(handleNode(value));
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.itemType(handleNode(value, appendPath(path, key), false));
break;
case OBJECT_KEY_TYPE:
- obj.keyType(handleNode(value));
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.keyType(handleNode(value, appendPath(path, key), false));
break;
case OBJECT_VALUE_TYPE:
- obj.valueType(handleNode(value));
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.valueType(handleNode(value, appendPath(path, key), false));
break;
case OBJECT_MERGE_POLICY:
- obj.mergePolicy(value.isNull() ? null : value.asText());
+ rejectNullReserved(value, key, appendPath(path, key));
+ obj.mergePolicy(requireString(value, key, appendPath(path, key)));
break;
case OBJECT_VALUE:
+ rejectNullReserved(value, key, appendPath(path, key));
hasValuePayload = true;
obj.value(handleValue(value));
break;
@@ -70,14 +102,22 @@ private Node handleNode(JsonNode node) {
if (node.size() != 1) {
throw new IllegalArgumentException("\"blueId\" nodes must be reference-only and cannot contain sibling fields.");
}
- obj.blueId(value.asText());
+ obj.blueId(requireString(value, key, appendPath(path, key)));
break;
case OBJECT_ITEMS:
+ rejectNullReserved(value, key, appendPath(path, key));
hasItemsPayload = true;
- obj.items(handleArray(value));
+ obj.items(handleArray(value, appendPath(path, key)));
break;
case OBJECT_BLUE:
- obj.blue(handleNode(value));
+ rejectNullReserved(value, key, appendPath(path, key));
+ if (!root) {
+ throw new IllegalArgumentException("\"blue\" is valid only on the root Source Document. Path: " + appendPath(path, key));
+ }
+ if (value.isArray()) {
+ throw new IllegalArgumentException("\"blue\" must be a string or object directive. Path: " + appendPath(path, key));
+ }
+ obj.blue(handleNode(value, appendPath(path, key), false));
break;
case LIST_CONTROL_PREVIOUS:
if (node.size() != 1) {
@@ -88,39 +128,60 @@ private Node handleNode(JsonNode node) {
case LIST_CONTROL_POS:
obj.position(handlePosition(value));
break;
+ case LIST_CONTROL_REPLACE:
+ properties.put(key, handleNode(value, appendPath(path, key), false));
+ break;
case OBJECT_SCHEMA:
- case "constraints":
+ rejectNullReserved(value, key, appendPath(path, key));
if (hasSchema) {
- throw new IllegalArgumentException("A Blue node cannot contain both \"schema\" and legacy \"constraints\".");
+ throw new IllegalArgumentException("A Blue node cannot contain more than one \"schema\" field.");
}
hasSchema = true;
- obj.schema(handleSchema(value));
+ obj.schema(handleSchema(value, appendPath(path, key)));
break;
+ case OBJECT_CONTRACTS:
+ if (!value.isObject()) {
+ throw new IllegalArgumentException("\"contracts\" must be an object. Path: " + appendPath(path, key));
+ }
+ obj.contracts(handleNode(value, appendPath(path, key), false));
+ break;
+ case "constraints":
+ throw new IllegalArgumentException("\"constraints\" is not part of the Blue Language 1.0 top-level vocabulary.");
default:
if ("properties".equals(key)) {
throw new IllegalArgumentException("\"properties\" is an internal field and must not appear in Blue documents.");
}
- properties.put(key, handleNode(value));
+ properties.put(key, handleNode(value, appendPath(path, key), false));
break;
}
}
int payloadKinds = 0;
if (hasValuePayload) payloadKinds++;
if (hasItemsPayload) payloadKinds++;
- if (!properties.isEmpty()) payloadKinds++;
+ if (properties.keySet().stream().anyMatch(key -> !isBlueImportsDirective(path, key))) {
+ payloadKinds++;
+ }
if (payloadKinds > 1) {
throw new IllegalArgumentException("A Blue node may contain only one payload kind: value, items, or object fields.");
}
if (obj.getPosition() != null && node.size() == 1) {
throw new IllegalArgumentException("\"$pos\" items must contain an overlay.");
}
+ if (properties.containsKey(LIST_CONTROL_REPLACE)) {
+ if (obj.getPosition() == null) {
+ throw new IllegalArgumentException("\"$replace\" is valid only inside a \"$pos\" list overlay. Path: " + appendPath(path, LIST_CONTROL_REPLACE));
+ }
+ if (node.size() != 2) {
+ throw new IllegalArgumentException("\"$replace\" cannot be combined with sibling overlay fields other than \"$pos\". Path: " + path);
+ }
+ }
validateMergePolicy(obj.getMergePolicy());
if (!properties.isEmpty()) {
obj.properties(properties);
}
return obj;
} else if (node.isArray()) {
- return new Node().items(handleArray(node));
+ return new Node().items(handleArray(node, path));
} else {
return new Node().value(handleValue(node)).inlineValue(true);
}
@@ -130,7 +191,13 @@ private Object handleValue(JsonNode node) {
if (node.isTextual()) {
return node.asText();
} else if (node.isBigInteger() || node.isInt() || node.isLong()) {
- return node.bigIntegerValue();
+ BigInteger value = node.bigIntegerValue();
+ BigInteger lowerBound = BigInteger.valueOf(-9007199254740991L);
+ BigInteger upperBound = BigInteger.valueOf(9007199254740991L);
+ if (value.compareTo(lowerBound) < 0 || value.compareTo(upperBound) > 0) {
+ throw new IllegalArgumentException("Unquoted integers outside [-9007199254740991, 9007199254740991] must be quoted and explicitly typed as Integer.");
+ }
+ return value;
} else if (node.isFloatingPointNumber()) {
return node.decimalValue();
} else if (node.isBoolean()) {
@@ -172,27 +239,240 @@ private void validateMergePolicy(String mergePolicy) {
}
}
- private List handleArray(JsonNode value) {
- if (value.isNull()) {
- return null;
- } else if (value.isObject()) {
- List singleItemList = new ArrayList<>();
- singleItemList.add(handleNode(value));
- return singleItemList;
- } else if (value.isArray()) {
+ private List handleArray(JsonNode value, String path) {
+ if (value.isArray()) {
ArrayNode arrayNode = (ArrayNode) value;
- return StreamSupport.stream(arrayNode.spliterator(), false)
- .map(this::handleNode)
- .collect(Collectors.toList());
+ List items = new ArrayList<>();
+ for (int i = 0; i < arrayNode.size(); i++) {
+ items.add(handleNode(arrayNode.get(i), appendPath(path, i), false));
+ }
+ return items;
} else {
- throw new IllegalArgumentException("Expected an array node");
+ throw new IllegalArgumentException("\"items\" must be a list. Path: " + path);
}
}
- private Schema handleSchema(JsonNode schemaNode) {
- if (schemaNode != null && schemaNode.isObject() && schemaNode.has("pattern")) {
- throw new IllegalArgumentException("\"schema.pattern\" is not part of the Blue language core; use contract/runtime validation.");
+ private Schema handleSchema(JsonNode schemaNode, String path) {
+ if (schemaNode == null || schemaNode.isNull()) {
+ return null;
+ }
+ if (!schemaNode.isObject()) {
+ throw new IllegalArgumentException("\"schema\" must be an object. Path: " + path);
}
+ for (Iterator it = schemaNode.fieldNames(); it.hasNext(); ) {
+ String key = it.next();
+ if (!ALLOWED_SCHEMA_KEYS.contains(key)) {
+ throw new IllegalArgumentException("\"schema." + key + "\" is not part of the Blue language core.");
+ }
+ }
+ validateSchemaValueShapes(schemaNode, path);
return UncheckedObjectMapper.YAML_MAPPER.convertValue(schemaNode, Schema.class);
}
+
+ private void validateSchemaValueShapes(JsonNode schemaNode, String path) {
+ requireBooleanKeyword(schemaNode, "required", path);
+ requireBooleanKeyword(schemaNode, "uniqueItems", path);
+
+ requireNonNegativeIntegerKeyword(schemaNode, "minLength", path);
+ requireNonNegativeIntegerKeyword(schemaNode, "maxLength", path);
+ requireNonNegativeIntegerKeyword(schemaNode, "minItems", path);
+ requireNonNegativeIntegerKeyword(schemaNode, "maxItems", path);
+ requireNonNegativeIntegerKeyword(schemaNode, "minFields", path);
+ requireNonNegativeIntegerKeyword(schemaNode, "maxFields", path);
+
+ requireNumericKeyword(schemaNode, "minimum", path);
+ requireNumericKeyword(schemaNode, "maximum", path);
+ requireNumericKeyword(schemaNode, "exclusiveMinimum", path);
+ requireNumericKeyword(schemaNode, "exclusiveMaximum", path);
+ requireNumericKeyword(schemaNode, "multipleOf", path);
+
+ JsonNode enumNode = schemaNode.get("enum");
+ if (enumNode != null) {
+ if (!enumNode.isArray()) {
+ throw new IllegalArgumentException("\"schema.enum\" must be a list. Path: " + appendPath(path, "enum"));
+ }
+ for (int i = 0; i < enumNode.size(); i++) {
+ requireEnumEntry(enumNode.get(i), appendPath(appendPath(path, "enum"), i));
+ }
+ }
+ }
+
+ private void requireBooleanKeyword(JsonNode schemaNode, String keyword, String path) {
+ JsonNode value = schemaNode.get(keyword);
+ if (value == null || value.isBoolean()) {
+ return;
+ }
+ throw new IllegalArgumentException("\"schema." + keyword + "\" must be a boolean. Path: " + appendPath(path, keyword));
+ }
+
+ private void requireEnumEntry(JsonNode value, String path) {
+ if (value == null || value.isNull() || value.isArray()) {
+ throw new IllegalArgumentException("\"schema.enum\" entries must be scalar values or explicit scalar nodes. Path: " + path);
+ }
+ if (!value.isObject()) {
+ return;
+ }
+ if (value.size() == 0 || value.has(LIST_CONTROL_EMPTY)) {
+ throw new IllegalArgumentException("\"schema.enum\" entries must be scalar values or explicit scalar nodes. Path: " + path);
+ }
+ Node enumNode = handleNode(value, path, false);
+ if (!isExplicitSchemaScalar(enumNode, true)) {
+ throw new IllegalArgumentException("\"schema.enum\" entries must be scalar values or explicit scalar nodes. Path: " + path);
+ }
+ }
+
+ private void requireNonNegativeIntegerKeyword(JsonNode schemaNode, String keyword, String path) {
+ JsonNode value = schemaNode.get(keyword);
+ if (value == null) {
+ return;
+ }
+ if (value.isIntegralNumber()) {
+ BigInteger integer = value.bigIntegerValue();
+ if (integer.signum() < 0 || integer.compareTo(BigInteger.valueOf(9007199254740991L)) > 0) {
+ throw new IllegalArgumentException("\"schema." + keyword + "\" must be a non-negative integer in the interoperable range. Path: " + appendPath(path, keyword));
+ }
+ return;
+ } else {
+ throw new IllegalArgumentException("\"schema." + keyword + "\" must be a non-negative integer. Path: " + appendPath(path, keyword));
+ }
+ }
+
+ private void requireNumericKeyword(JsonNode schemaNode, String keyword, String path) {
+ JsonNode value = schemaNode.get(keyword);
+ if (value == null) {
+ return;
+ }
+ if (value.isNumber()) {
+ if (value.isIntegralNumber()) {
+ BigInteger integer = value.bigIntegerValue();
+ BigInteger lowerBound = BigInteger.valueOf(-9007199254740991L);
+ BigInteger upperBound = BigInteger.valueOf(9007199254740991L);
+ if (integer.compareTo(lowerBound) < 0 || integer.compareTo(upperBound) > 0) {
+ throw new IllegalArgumentException("\"schema." + keyword + "\" unquoted integer is outside the interoperable range. Path: " + appendPath(path, keyword));
+ }
+ }
+ return;
+ }
+ if (value.isObject()) {
+ Node numericNode = handleNode(value, appendPath(path, keyword), false);
+ if (isExplicitNumericValue(numericNode)) {
+ return;
+ }
+ }
+ throw new IllegalArgumentException("\"schema." + keyword + "\" must be numeric or an explicit numeric scalar node. Path: " + appendPath(path, keyword));
+ }
+
+ private boolean isExplicitNumericValue(Node node) {
+ if (!isExplicitSchemaScalar(node, true)) {
+ return false;
+ }
+ if (node.getValue() instanceof Number) {
+ return node.getType() == null || isNumericType(node.getType());
+ }
+ if (!(node.getRawValue() instanceof String)) {
+ return false;
+ }
+ String value = (String) node.getRawValue();
+ if (isIntegerType(node.getType())) {
+ return parseExplicitInteger(value) != null;
+ }
+ if (isDoubleType(node.getType())) {
+ BlueNumbers.toCanonicalDoubleValue(value);
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isExplicitSchemaScalar(Node node, boolean allowType) {
+ if (node == null || node.getValue() == null) {
+ return false;
+ }
+ if ((!allowType && node.getType() != null)
+ || node.getName() != null
+ || node.getDescription() != null
+ || node.getItemType() != null
+ || node.getKeyType() != null
+ || node.getValueType() != null
+ || node.getItems() != null
+ || node.getProperties() != null
+ || node.getContracts() != null
+ || node.getBlueId() != null
+ || node.getSchema() != null
+ || node.getMergePolicy() != null
+ || node.getPreviousBlueId() != null
+ || node.getPosition() != null
+ || node.getBlue() != null) {
+ return false;
+ }
+ return node.getType() == null || isScalarType(node.getType());
+ }
+
+ private boolean isScalarType(Node type) {
+ return isCoreType(type, TEXT_TYPE_BLUE_ID, "Text")
+ || isCoreType(type, INTEGER_TYPE_BLUE_ID, "Integer")
+ || isCoreType(type, DOUBLE_TYPE_BLUE_ID, "Double")
+ || isCoreType(type, BOOLEAN_TYPE_BLUE_ID, "Boolean");
+ }
+
+ private boolean isNumericType(Node type) {
+ return isIntegerType(type) || isDoubleType(type);
+ }
+
+ private BigInteger parseExplicitInteger(String value) {
+ if (value == null || !value.matches("-?(0|[1-9]\\d*)")) {
+ throw new IllegalArgumentException("Explicit Integer scalar values must be canonical decimal strings.");
+ }
+ return new BigInteger(value);
+ }
+
+ private boolean isIntegerType(Node type) {
+ return isCoreType(type, INTEGER_TYPE_BLUE_ID, "Integer");
+ }
+
+ private boolean isDoubleType(Node type) {
+ return isCoreType(type, DOUBLE_TYPE_BLUE_ID, "Double");
+ }
+
+ private boolean isCoreType(Node type, String blueId, String alias) {
+ if (type == null) {
+ return false;
+ }
+ if (blueId.equals(type.getBlueId())) {
+ return true;
+ }
+ return type.isInlineValue() && alias.equals(type.getValue());
+ }
+
+ private void rejectNullReserved(JsonNode node, String field, String path) {
+ if (node.isNull()) {
+ throw new IllegalArgumentException("\"" + field + "\" must not be null; omit the field instead. Path: " + path);
+ }
+ }
+
+ private String requireString(JsonNode node, String field, String path) {
+ if (!node.isTextual()) {
+ throw new IllegalArgumentException("\"" + field + "\" must be a string. Path: " + path);
+ }
+ return node.asText();
+ }
+
+ private String appendPath(String path, String segment) {
+ String prefix = path == null || path.isEmpty() ? "/" : path;
+ if ("/".equals(prefix)) {
+ return "/" + escapePathSegment(segment);
+ }
+ return prefix + "/" + escapePathSegment(segment);
+ }
+
+ private String appendPath(String path, int index) {
+ return appendPath(path, String.valueOf(index));
+ }
+
+ private boolean isBlueImportsDirective(String path, String key) {
+ return "/blue".equals(path) && "imports".equals(key);
+ }
+
+ private String escapePathSegment(String segment) {
+ return segment.replace("~", "~0").replace("/", "~1");
+ }
}
diff --git a/src/main/java/blue/language/model/Schema.java b/src/main/java/blue/language/model/Schema.java
index fe13c50..f4208fb 100644
--- a/src/main/java/blue/language/model/Schema.java
+++ b/src/main/java/blue/language/model/Schema.java
@@ -1,8 +1,5 @@
package blue.language.model;
-import com.fasterxml.jackson.annotation.JsonFormat;
-import com.fasterxml.jackson.annotation.JsonAlias;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
@@ -15,7 +12,6 @@
public class Schema implements Cloneable {
private Node required;
- private Node allowMultiple;
private Node minLength;
private Node maxLength;
private Node minimum;
@@ -29,18 +25,12 @@ public class Schema implements Cloneable {
private Node minFields;
private Node maxFields;
@JsonProperty("enum")
- @JsonAlias("options")
- @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
private List enumValues;
public Node getRequired() {
return required;
}
- public Node getAllowMultiple() {
- return allowMultiple;
- }
-
public Node getMinLength() {
return minLength;
}
@@ -85,18 +75,32 @@ public Boolean getRequiredValue() {
return required == null ? null : getBooleanFromObject(required.getValue());
}
- public Boolean getAllowMultipleValue() {
- return allowMultiple == null ? null : getBooleanFromObject(allowMultiple.getValue());
- }
-
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMinLengthExact()}.
+ */
+ @Deprecated
public Integer getMinLengthValue() {
return minLength == null ? null : getIntegerFromObject(minLength.getValue());
}
+ public BigInteger getMinLengthExact() {
+ return minLength == null ? null : getBigIntegerFromObject(minLength.getValue());
+ }
+
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMaxLengthExact()}.
+ */
+ @Deprecated
public Integer getMaxLengthValue() {
return maxLength == null ? null : getIntegerFromObject(maxLength.getValue());
}
+ public BigInteger getMaxLengthExact() {
+ return maxLength == null ? null : getBigIntegerFromObject(maxLength.getValue());
+ }
+
public BigDecimal getMinimumValue() {
return minimum == null ? null : getBigDecimalFromObject(minimum.getValue());
}
@@ -117,14 +121,32 @@ public BigDecimal getMultipleOfValue() {
return multipleOf == null ? null : getBigDecimalFromObject(multipleOf.getValue());
}
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMinItemsExact()}.
+ */
+ @Deprecated
public Integer getMinItemsValue() {
return minItems == null ? null : getIntegerFromObject(minItems.getValue());
}
+ public BigInteger getMinItemsExact() {
+ return minItems == null ? null : getBigIntegerFromObject(minItems.getValue());
+ }
+
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMaxItemsExact()}.
+ */
+ @Deprecated
public Integer getMaxItemsValue() {
return maxItems == null ? null : getIntegerFromObject(maxItems.getValue());
}
+ public BigInteger getMaxItemsExact() {
+ return maxItems == null ? null : getBigIntegerFromObject(maxItems.getValue());
+ }
+
public Boolean getUniqueItemsValue() {
return uniqueItems == null ? null : getBooleanFromObject(uniqueItems.getValue());
}
@@ -142,26 +164,34 @@ public List getEnum() {
return enumValues;
}
- @JsonIgnore
- public List getOptions() {
- return enumValues;
- }
-
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMinFieldsExact()}.
+ */
+ @Deprecated
public Integer getMinFieldsValue() {
return minFields == null ? null : getIntegerFromObject(minFields.getValue());
}
+ public BigInteger getMinFieldsExact() {
+ return minFields == null ? null : getBigIntegerFromObject(minFields.getValue());
+ }
+
+ /**
+ * @deprecated Blue Language 1.0 count and length schema keywords use the
+ * interoperable JSON integer range. Use {@link #getMaxFieldsExact()}.
+ */
+ @Deprecated
public Integer getMaxFieldsValue() {
return maxFields == null ? null : getIntegerFromObject(maxFields.getValue());
}
- public Schema required(Node required) {
- this.required = required;
- return this;
+ public BigInteger getMaxFieldsExact() {
+ return maxFields == null ? null : getBigIntegerFromObject(maxFields.getValue());
}
- public Schema allowMultiple(Node allowMultiple) {
- this.allowMultiple = allowMultiple;
+ public Schema required(Node required) {
+ this.required = required;
return this;
}
@@ -230,23 +260,18 @@ public Schema enumValues(List enumValues) {
return this;
}
- public Schema options(List options) {
- this.enumValues = options;
- return this;
- }
-
public Schema required(Boolean required) {
this.required = new Node().value(required);
return this;
}
- public Schema allowMultiple(Boolean allowMultiple) {
- this.allowMultiple = new Node().value(allowMultiple);
+ public Schema minLength(Integer minLength) {
+ this.minLength = new Node().value(BigInteger.valueOf(minLength));
return this;
}
- public Schema minLength(Integer minLength) {
- this.minLength = new Node().value(BigInteger.valueOf(minLength));
+ public Schema minLength(BigInteger minLength) {
+ this.minLength = new Node().value(minLength);
return this;
}
@@ -255,6 +280,11 @@ public Schema maxLength(Integer maxLength) {
return this;
}
+ public Schema maxLength(BigInteger maxLength) {
+ this.maxLength = new Node().value(maxLength);
+ return this;
+ }
+
public Schema minimum(BigDecimal minimum) {
this.minimum = new Node().value(minimum);
return this;
@@ -285,11 +315,21 @@ public Schema minItems(Integer minItems) {
return this;
}
+ public Schema minItems(BigInteger minItems) {
+ this.minItems = new Node().value(minItems);
+ return this;
+ }
+
public Schema maxItems(Integer maxItems) {
this.maxItems = new Node().value(BigInteger.valueOf(maxItems));
return this;
}
+ public Schema maxItems(BigInteger maxItems) {
+ this.maxItems = new Node().value(maxItems);
+ return this;
+ }
+
public Schema uniqueItems(Boolean uniqueItems) {
this.uniqueItems = new Node().value(uniqueItems);
return this;
@@ -300,18 +340,27 @@ public Schema minFields(Integer minFields) {
return this;
}
+ public Schema minFields(BigInteger minFields) {
+ this.minFields = new Node().value(minFields);
+ return this;
+ }
+
public Schema maxFields(Integer maxFields) {
this.maxFields = new Node().value(BigInteger.valueOf(maxFields));
return this;
}
+ public Schema maxFields(BigInteger maxFields) {
+ this.maxFields = new Node().value(maxFields);
+ return this;
+ }
+
@Override
public Schema clone() {
try {
Schema cloned = (Schema) super.clone();
if (this.required != null) cloned.required = this.required.clone();
- if (this.allowMultiple != null) cloned.allowMultiple = this.allowMultiple.clone();
if (this.minLength != null) cloned.minLength = this.minLength.clone();
if (this.maxLength != null) cloned.maxLength = this.maxLength.clone();
if (this.minimum != null) cloned.minimum = this.minimum.clone();
@@ -341,19 +390,18 @@ public Schema clone() {
public String toString() {
return "Schema{" +
"required=" + getRequiredValue() +
- ", allowMultiple=" + getAllowMultipleValue() +
- ", minLength=" + getMinLengthValue() +
- ", maxLength=" + getMaxLengthValue() +
+ ", minLength=" + getMinLengthExact() +
+ ", maxLength=" + getMaxLengthExact() +
", minimum=" + getMinimumValue() +
", maximum=" + getMaximumValue() +
", exclusiveMinimum=" + getExclusiveMinimumValue() +
", exclusiveMaximum=" + getExclusiveMaximumValue() +
", multipleOf=" + getMultipleOfValue() +
- ", minItems=" + getMinItemsValue() +
- ", maxItems=" + getMaxItemsValue() +
+ ", minItems=" + getMinItemsExact() +
+ ", maxItems=" + getMaxItemsExact() +
", uniqueItems=" + getUniqueItemsValue() +
- ", minFields=" + getMinFieldsValue() +
- ", maxFields=" + getMaxFieldsValue() +
+ ", minFields=" + getMinFieldsExact() +
+ ", maxFields=" + getMaxFieldsExact() +
", enum=" + enumValues +
'}';
}
diff --git a/src/main/java/blue/language/preprocess/Preprocessor.java b/src/main/java/blue/language/preprocess/Preprocessor.java
index e7601ed..219ee72 100644
--- a/src/main/java/blue/language/preprocess/Preprocessor.java
+++ b/src/main/java/blue/language/preprocess/Preprocessor.java
@@ -3,18 +3,25 @@
import blue.language.NodeProvider;
import blue.language.model.Node;
import blue.language.preprocess.processor.InferBasicTypesForUntypedValues;
+import blue.language.preprocess.processor.NormalizeListPlaceholders;
import blue.language.preprocess.processor.ReplaceInlineValuesForTypeAttributesWithImports;
import blue.language.provider.BootstrapProvider;
import blue.language.utils.BlueIdCalculator;
+import blue.language.utils.BlueIds;
import blue.language.utils.NodeExtender;
import blue.language.utils.NodeProviderWrapper;
+import blue.language.utils.Nodes;
import blue.language.utils.limits.PathLimits;
import java.io.IOException;
import java.io.InputStream;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import static blue.language.utils.Properties.DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP;
+
import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER;
public class Preprocessor {
@@ -40,6 +47,10 @@ public Preprocessor() {
}
public Node preprocess(Node document) {
+ return preprocessWithDefaultBlue(document);
+ }
+
+ public Node preprocessWithoutDefaultBlue(Node document) {
return preprocess(document, null);
}
@@ -48,47 +59,100 @@ public Node preprocessWithDefaultBlue(Node document) {
}
public Node preprocess(Node document, Node defaultBlue) {
- Node processedDocument = document.clone();
- Node blueNode = processedDocument.getBlue();
-
- if (blueNode == null) {
- blueNode = defaultBlue.clone();
+ Node processedDocument = new NormalizeListPlaceholders().process(document.clone());
+ if (defaultBlue != null) {
+ processedDocument = applyStandardBaseline(processedDocument);
}
+ processedDocument = applyPortableImports(processedDocument);
+ Node blueNode = processedDocument.getBlue();
if (blueNode != null) {
+ processedDocument = applyDeclaredBlueTransformations(processedDocument, blueNode);
+ }
- new NodeExtender(nodeProvider).extend(blueNode, PathLimits.withSinglePath("/*"));
+ return processedDocument;
+ }
+
+ private Node applyStandardBaseline(Node document) {
+ Node transformed = new ReplaceInlineValuesForTypeAttributesWithImports(DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP)
+ .process(document);
+ return new InferBasicTypesForUntypedValues().process(transformed);
+ }
- if (blueNode.getItems() != null) {
- List transformations = blueNode.getItems();
+ private Node applyDeclaredBlueTransformations(Node processedDocument, Node blueNode) {
+ Node extendedBlue = blueNode.clone();
+ new NodeExtender(nodeProvider).extend(extendedBlue, PathLimits.withSinglePath("/*"));
- for (Node transformation : transformations) {
- Optional processor = processorProvider.getProcessor(transformation);
- if (processor.isPresent()) {
- processedDocument = processor.get().process(processedDocument);
- } else {
- throw new IllegalArgumentException("No processor found for transformation: " + transformation);
- }
- }
+ if (extendedBlue.getItems() != null) {
+ List transformations = extendedBlue.getItems();
- processedDocument.blue(null);
+ for (Node transformation : transformations) {
+ Optional processor = processorProvider.getProcessor(transformation);
+ if (processor.isPresent()) {
+ processedDocument = processor.get().process(processedDocument);
+ } else {
+ throw new IllegalArgumentException("No processor found for transformation: " + transformation);
+ }
}
}
+ processedDocument.blue(null);
return processedDocument;
}
+ private Node applyPortableImports(Node document) {
+ Node blueNode = document.getBlue();
+ if (blueNode == null || blueNode.getProperties() == null || !blueNode.getProperties().containsKey("imports")) {
+ return document;
+ }
+
+ Node importsNode = blueNode.getProperties().get("imports");
+ if (importsNode == null || importsNode.getProperties() == null || importsNode.getValue() != null
+ || importsNode.getItems() != null || importsNode.getBlueId() != null) {
+ throw new IllegalArgumentException("\"blue.imports\" must be an object mapping aliases to pure references.");
+ }
+
+ Map mappings = new LinkedHashMap<>();
+ for (Map.Entry entry : importsNode.getProperties().entrySet()) {
+ String alias = entry.getKey();
+ Node reference = entry.getValue();
+ if (reference == null || !reference.isReferenceOnly()) {
+ throw new IllegalArgumentException("\"blue.imports." + alias + "\" must be a pure reference.");
+ }
+ String blueId = BlueIds.requirePlainBlueId(reference.getBlueId(), "blue.imports." + alias);
+ String defaultBlueId = DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP.get(alias);
+ if (defaultBlueId != null && !defaultBlueId.equals(blueId)) {
+ throw new IllegalArgumentException("\"blue.imports\" cannot redefine default Blue alias \"" + alias + "\".");
+ }
+ mappings.put(alias, blueId);
+ }
+
+ Node transformed = new ReplaceInlineValuesForTypeAttributesWithImports(mappings).process(document);
+ Node transformedBlue = transformed.getBlue();
+ if (transformedBlue != null && transformedBlue.getProperties() != null) {
+ Map remainingProperties = new LinkedHashMap<>(transformedBlue.getProperties());
+ remainingProperties.remove("imports");
+ transformedBlue.properties(remainingProperties.isEmpty() ? null : remainingProperties);
+ }
+ if (transformedBlue != null && Nodes.isEmptyNode(transformedBlue)) {
+ transformed.blue(null);
+ }
+ return transformed;
+ }
+
public static TransformationProcessorProvider getStandardProvider() {
return new TransformationProcessorProvider() {
private static final String REPLACE_INLINE_TYPES = "27B7fuxQCS1VAptiCPc2RMkKoutP5qxkh3uDxZ7dr6Eo";
+ private static final String LEGACY_REPLACE_INLINE_TYPES = "53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7";
private static final String INFER_BASIC_TYPES = "FGYuTXwaoSKfZmpTysLTLsb8WzSqf43384rKZDkXhxD4";
+ private static final String LEGACY_INFER_BASIC_TYPES = "49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i";
@Override
public Optional getProcessor(Node transformation) {
String blueId = transformation.getAsText("/type/blueId");
- if (REPLACE_INLINE_TYPES.equals(blueId))
+ if (REPLACE_INLINE_TYPES.equals(blueId) || LEGACY_REPLACE_INLINE_TYPES.equals(blueId))
return Optional.of(new ReplaceInlineValuesForTypeAttributesWithImports(transformation));
- else if (INFER_BASIC_TYPES.equals(blueId))
+ else if (INFER_BASIC_TYPES.equals(blueId) || LEGACY_INFER_BASIC_TYPES.equals(blueId))
return Optional.of(new InferBasicTypesForUntypedValues());
return Optional.empty();
}
diff --git a/src/main/java/blue/language/preprocess/processor/NormalizeListPlaceholders.java b/src/main/java/blue/language/preprocess/processor/NormalizeListPlaceholders.java
new file mode 100644
index 0000000..c6dfd7a
--- /dev/null
+++ b/src/main/java/blue/language/preprocess/processor/NormalizeListPlaceholders.java
@@ -0,0 +1,146 @@
+package blue.language.preprocess.processor;
+
+import blue.language.model.Node;
+import blue.language.model.Schema;
+import blue.language.preprocess.TransformationProcessor;
+import blue.language.utils.Nodes;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static blue.language.utils.Properties.LIST_CONTROL_EMPTY;
+
+public class NormalizeListPlaceholders implements TransformationProcessor {
+
+ @Override
+ public Node process(Node document) {
+ return normalizeRoot(document);
+ }
+
+ private Node normalizeRoot(Node node) {
+ if (node == null) {
+ return null;
+ }
+ return normalizeNode(node, false, "/");
+ }
+
+ private Node normalizeObjectField(Node node, String path) {
+ if (node == null) {
+ return null;
+ }
+ Node normalized = normalizeNode(node, false, path);
+ return Nodes.isEmptyNode(normalized) ? null : normalized;
+ }
+
+ private Node normalizeListElement(Node node, String path) {
+ if (node == null || Nodes.isEmptyNode(node)) {
+ return Nodes.emptyPlaceholder();
+ }
+ if (node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_EMPTY)) {
+ Nodes.validateEmptyPlaceholder(node, path);
+ return node.clone();
+ }
+ Node normalized = normalizeNode(node, true, path);
+ return Nodes.isEmptyNode(normalized) ? Nodes.emptyPlaceholder() : normalized;
+ }
+
+ private Node normalizeNode(Node node, boolean listElement, String path) {
+ Node normalized = node.clone();
+
+ if (listElement && normalized.getProperties() != null && normalized.getProperties().containsKey(LIST_CONTROL_EMPTY)) {
+ Nodes.validateEmptyPlaceholder(normalized, path);
+ return normalized;
+ }
+
+ if (normalized.getType() != null) {
+ normalized.type(normalizeNode(normalized.getType(), false, append(path, "type")));
+ }
+ if (normalized.getItemType() != null) {
+ normalized.itemType(normalizeNode(normalized.getItemType(), false, append(path, "itemType")));
+ }
+ if (normalized.getKeyType() != null) {
+ normalized.keyType(normalizeNode(normalized.getKeyType(), false, append(path, "keyType")));
+ }
+ if (normalized.getValueType() != null) {
+ normalized.valueType(normalizeNode(normalized.getValueType(), false, append(path, "valueType")));
+ }
+ if (normalized.getBlue() != null) {
+ normalized.blue(normalizeNode(normalized.getBlue(), false, append(path, "blue")));
+ }
+ if (normalized.getContracts() != null) {
+ normalized.contracts(normalizeNode(normalized.getContracts(), false, append(path, "contracts")));
+ }
+ if (normalized.getSchema() != null) {
+ normalizeSchema(normalized.getSchema(), append(path, "schema"));
+ }
+
+ if (normalized.getItems() != null) {
+ List items = new ArrayList<>(normalized.getItems().size());
+ for (int i = 0; i < normalized.getItems().size(); i++) {
+ items.add(normalizeListElement(normalized.getItems().get(i), append(path, "items", i)));
+ }
+ normalized.items(items);
+ }
+
+ if (normalized.getProperties() != null) {
+ Map properties = new LinkedHashMap<>();
+ for (Map.Entry entry : normalized.getProperties().entrySet()) {
+ Node child = normalizeObjectField(entry.getValue(), append(path, entry.getKey()));
+ if (child != null) {
+ properties.put(entry.getKey(), child);
+ }
+ }
+ normalized.properties(properties.isEmpty() ? null : properties);
+ }
+
+ return normalized;
+ }
+
+ private void normalizeSchema(Schema schema, String path) {
+ schema.required(normalizeObjectField(schema.getRequired(), append(path, "required")));
+ schema.minLength(normalizeObjectField(schema.getMinLength(), append(path, "minLength")));
+ schema.maxLength(normalizeObjectField(schema.getMaxLength(), append(path, "maxLength")));
+ schema.minimum(normalizeObjectField(schema.getMinimum(), append(path, "minimum")));
+ schema.maximum(normalizeObjectField(schema.getMaximum(), append(path, "maximum")));
+ schema.exclusiveMinimum(normalizeObjectField(schema.getExclusiveMinimum(), append(path, "exclusiveMinimum")));
+ schema.exclusiveMaximum(normalizeObjectField(schema.getExclusiveMaximum(), append(path, "exclusiveMaximum")));
+ schema.multipleOf(normalizeObjectField(schema.getMultipleOf(), append(path, "multipleOf")));
+ schema.minItems(normalizeObjectField(schema.getMinItems(), append(path, "minItems")));
+ schema.maxItems(normalizeObjectField(schema.getMaxItems(), append(path, "maxItems")));
+ schema.uniqueItems(normalizeObjectField(schema.getUniqueItems(), append(path, "uniqueItems")));
+ schema.minFields(normalizeObjectField(schema.getMinFields(), append(path, "minFields")));
+ schema.maxFields(normalizeObjectField(schema.getMaxFields(), append(path, "maxFields")));
+ if (schema.getEnum() != null) {
+ List enumValues = new ArrayList<>(schema.getEnum().size());
+ for (int i = 0; i < schema.getEnum().size(); i++) {
+ String enumPath = append(path, "enum", i);
+ Node enumValue = normalizeObjectField(schema.getEnum().get(i), enumPath);
+ if (enumValue == null
+ || Nodes.isEmptyPlaceholder(enumValue)
+ || (enumValue.getProperties() != null && enumValue.getProperties().containsKey(LIST_CONTROL_EMPTY))) {
+ throw new IllegalArgumentException("schema.enum entries must be scalar values or explicit scalar nodes. Path: " + enumPath);
+ }
+ enumValues.add(enumValue);
+ }
+ schema.enumValues(enumValues);
+ }
+ }
+
+ private static String append(String path, String segment) {
+ String prefix = path == null || path.isEmpty() ? "/" : path;
+ if ("/".equals(prefix)) {
+ return "/" + escape(segment);
+ }
+ return prefix + "/" + escape(segment);
+ }
+
+ private static String append(String path, String segment, int index) {
+ return append(append(path, segment), String.valueOf(index));
+ }
+
+ private static String escape(String segment) {
+ return segment.replace("~", "~0").replace("/", "~1");
+ }
+}
diff --git a/src/main/java/blue/language/processor/BatchPatchResult.java b/src/main/java/blue/language/processor/BatchPatchResult.java
index 479786d..2b755e3 100644
--- a/src/main/java/blue/language/processor/BatchPatchResult.java
+++ b/src/main/java/blue/language/processor/BatchPatchResult.java
@@ -1,6 +1,7 @@
package blue.language.processor;
import blue.language.snapshot.FrozenNode;
+import blue.language.processor.model.JsonPatch;
import java.util.ArrayList;
import java.util.Collections;
@@ -12,6 +13,7 @@ final class BatchPatchResult {
private final FrozenNode canonicalRoot;
private final FrozenNode resolvedRoot;
private final List updates;
+ private final UpdatePlan updatePlan;
private final long patchPlanningNanos;
private final long conformanceNanos;
private final long buildUpdatesNanos;
@@ -32,6 +34,22 @@ final class BatchPatchResult {
this.resolvedRoot = Objects.requireNonNull(resolvedRoot, "resolvedRoot");
this.updates = Collections.unmodifiableList(new ArrayList<>(
Objects.requireNonNull(updates, "updates")));
+ this.updatePlan = null;
+ this.patchPlanningNanos = patchPlanningNanos;
+ this.conformanceNanos = conformanceNanos;
+ this.buildUpdatesNanos = buildUpdatesNanos;
+ }
+
+ BatchPatchResult(FrozenNode canonicalRoot,
+ FrozenNode resolvedRoot,
+ UpdatePlan updatePlan,
+ long patchPlanningNanos,
+ long conformanceNanos,
+ long buildUpdatesNanos) {
+ this.canonicalRoot = Objects.requireNonNull(canonicalRoot, "canonicalRoot");
+ this.resolvedRoot = Objects.requireNonNull(resolvedRoot, "resolvedRoot");
+ this.updates = null;
+ this.updatePlan = Objects.requireNonNull(updatePlan, "updatePlan");
this.patchPlanningNanos = patchPlanningNanos;
this.conformanceNanos = conformanceNanos;
this.buildUpdatesNanos = buildUpdatesNanos;
@@ -46,7 +64,7 @@ FrozenNode resolvedRoot() {
}
List updates() {
- return updates;
+ return updates != null ? updates : updatePlan.build(null);
}
long patchPlanningNanos() {
@@ -60,4 +78,105 @@ long conformanceNanos() {
long buildUpdatesNanos() {
return buildUpdatesNanos;
}
+
+ BatchPatchResult withMaterializationMetrics(DocumentProcessingRuntime.UpdateMaterializationMetrics metrics) {
+ if (updatePlan != null) {
+ return new BatchPatchResult(canonicalRoot,
+ resolvedRoot,
+ updatePlan.build(metrics),
+ patchPlanningNanos,
+ conformanceNanos,
+ buildUpdatesNanos);
+ }
+ List rebound = new ArrayList<>(updates.size());
+ for (DocumentProcessingRuntime.DocumentUpdateData update : updates) {
+ rebound.add(update.withMaterializationMetrics(metrics));
+ }
+ return new BatchPatchResult(canonicalRoot,
+ resolvedRoot,
+ rebound,
+ patchPlanningNanos,
+ conformanceNanos,
+ buildUpdatesNanos);
+ }
+
+ static final class UpdatePlan {
+ private final List records;
+ private final FrozenNode preConformanceResolvedRoot;
+ private final FrozenNode finalResolvedRoot;
+ private final List generatedPaths;
+ private final boolean includeGeneratedUpdates;
+
+ UpdatePlan(List records,
+ FrozenNode preConformanceResolvedRoot,
+ FrozenNode finalResolvedRoot,
+ List generatedPaths,
+ boolean includeGeneratedUpdates) {
+ this.records = Collections.unmodifiableList(new ArrayList<>(
+ Objects.requireNonNull(records, "records")));
+ this.preConformanceResolvedRoot = Objects.requireNonNull(preConformanceResolvedRoot,
+ "preConformanceResolvedRoot");
+ this.finalResolvedRoot = Objects.requireNonNull(finalResolvedRoot, "finalResolvedRoot");
+ this.generatedPaths = generatedPaths == null
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList<>(generatedPaths));
+ this.includeGeneratedUpdates = includeGeneratedUpdates;
+ }
+
+ List build(
+ DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics) {
+ List built = new ArrayList<>();
+ ImmutablePatchPlanner finalResolvedPlanner = ImmutablePatchPlanner.forFrozen(finalResolvedRoot);
+ for (BatchPatchRecord record : records) {
+ FrozenNode after = null;
+ if (record.op() != JsonPatch.Op.REMOVE) {
+ after = hasLaterOverlappingPatch(record)
+ ? record.afterAtPatchTime()
+ : finalResolvedPlanner.read(record.path());
+ }
+ built.add(new DocumentProcessingRuntime.DocumentUpdateData(record.path(),
+ record.beforeAtPatchTime(),
+ after,
+ record.op(),
+ record.originScope(),
+ record.cascadeScopes(),
+ materializationMetrics));
+ }
+ if (includeGeneratedUpdates && !generatedPaths.isEmpty()) {
+ ImmutablePatchPlanner preConformancePlanner =
+ ImmutablePatchPlanner.forFrozen(preConformanceResolvedRoot);
+ for (String path : generatedPaths) {
+ FrozenNode before = preConformancePlanner.read(path);
+ FrozenNode after = finalResolvedPlanner.read(path);
+ built.add(new DocumentProcessingRuntime.DocumentUpdateData(path,
+ before,
+ after,
+ before == null ? JsonPatch.Op.ADD : JsonPatch.Op.REPLACE,
+ originScopeForGeneratedUpdate(),
+ Collections.singletonList("/"),
+ materializationMetrics));
+ }
+ }
+ return Collections.unmodifiableList(built);
+ }
+
+ private String originScopeForGeneratedUpdate() {
+ return records.isEmpty() ? "/" : records.get(0).originScope();
+ }
+
+ private boolean hasLaterOverlappingPatch(BatchPatchRecord current) {
+ int currentIndex = records.indexOf(current);
+ for (int i = currentIndex + 1; i < records.size(); i++) {
+ if (pathsOverlap(current.path(), records.get(i).path())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean pathsOverlap(String first, String second) {
+ return blue.language.processor.util.PointerUtils.descendantOrEqual(first, second)
+ || blue.language.processor.util.PointerUtils.descendantOrEqual(second, first);
+ }
+ }
}
diff --git a/src/main/java/blue/language/processor/BatchPatchTransaction.java b/src/main/java/blue/language/processor/BatchPatchTransaction.java
index f9bc9ad..bfce72b 100644
--- a/src/main/java/blue/language/processor/BatchPatchTransaction.java
+++ b/src/main/java/blue/language/processor/BatchPatchTransaction.java
@@ -11,6 +11,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
final class BatchPatchTransaction {
@@ -18,18 +19,34 @@ final class BatchPatchTransaction {
private final List patches;
private final DocumentProcessingRuntime.PlanningContext planning;
private final ConformanceEngine conformanceEngine;
+ private final ConformancePlannerOverride conformancePlannerOverride;
private final DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics;
+ private final boolean buildUpdates;
BatchPatchTransaction(String originScopePath,
List patches,
DocumentProcessingRuntime.PlanningContext planning,
ConformanceEngine conformanceEngine,
+ ConformancePlannerOverride conformancePlannerOverride,
DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics) {
+ this(originScopePath, patches, planning, conformanceEngine, conformancePlannerOverride,
+ materializationMetrics, true);
+ }
+
+ BatchPatchTransaction(String originScopePath,
+ List patches,
+ DocumentProcessingRuntime.PlanningContext planning,
+ ConformanceEngine conformanceEngine,
+ ConformancePlannerOverride conformancePlannerOverride,
+ DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics,
+ boolean buildUpdates) {
this.originScopePath = originScopePath;
this.patches = Collections.unmodifiableList(new ArrayList<>(patches));
this.planning = planning;
this.conformanceEngine = conformanceEngine;
+ this.conformancePlannerOverride = conformancePlannerOverride;
this.materializationMetrics = materializationMetrics;
+ this.buildUpdates = buildUpdates;
}
BatchPatchResult apply() {
@@ -57,16 +74,35 @@ BatchPatchResult apply() {
long patchPlanningNanos = System.nanoTime() - planningStart;
long conformanceStart = System.nanoTime();
+ FrozenNode preConformanceResolved = workingResolved;
ConformancePlan conformancePlan = planBatchConformance(workingCanonical, workingResolved, records);
long conformanceNanos = System.nanoTime() - conformanceStart;
FrozenNode finalCanonical = conformancePlan.canonicalRoot() != null
? conformancePlan.canonicalRoot()
: workingCanonical;
FrozenNode finalResolved = conformancePlan.root();
+ boolean includeGeneratedUpdates = conformancePlannerOverride != null && conformancePlannerOverride.applies();
- long buildUpdatesStart = System.nanoTime();
- List updates = buildUpdates(records, finalResolved);
- long buildUpdatesNanos = System.nanoTime() - buildUpdatesStart;
+ BatchPatchResult.UpdatePlan updatePlan = new BatchPatchResult.UpdatePlan(records,
+ preConformanceResolved,
+ finalResolved,
+ conformancePlan.changedPaths(),
+ includeGeneratedUpdates);
+ long buildUpdatesNanos = 0L;
+ List updates = null;
+ if (buildUpdates) {
+ long buildUpdatesStart = System.nanoTime();
+ updates = updatePlan.build(materializationMetrics);
+ buildUpdatesNanos = System.nanoTime() - buildUpdatesStart;
+ }
+ if (!buildUpdates) {
+ return new BatchPatchResult(finalCanonical,
+ finalResolved,
+ updatePlan,
+ patchPlanningNanos,
+ conformanceNanos,
+ buildUpdatesNanos);
+ }
return new BatchPatchResult(finalCanonical,
finalResolved,
updates,
@@ -78,22 +114,46 @@ BatchPatchResult apply() {
private ConformancePlan planBatchConformance(FrozenNode canonicalRoot,
FrozenNode resolvedRoot,
List records) {
- if (conformanceEngine == null) {
+ boolean hasOverride = conformancePlannerOverride != null && conformancePlannerOverride.applies();
+ if (conformanceEngine == null && !hasOverride) {
return ConformancePlan.unchanged(canonicalRoot, resolvedRoot);
}
List changedPaths = new ArrayList<>();
+ List changedPathRecords = new ArrayList<>();
for (BatchPatchRecord record : records) {
if (record.processorManagedConformanceBypass()) {
continue;
}
if (hasTypedNodeBetweenOriginAndPath(resolvedRoot, record.originScope(), record.path())) {
changedPaths.add(record.path());
+ changedPathRecords.add(new ConformanceChangedPath(record.path(), record.originScope()));
}
}
if (changedPaths.isEmpty()) {
return ConformancePlan.unchanged(canonicalRoot, resolvedRoot);
}
- return conformanceEngine.planGeneralization(canonicalRoot, resolvedRoot, changedPaths);
+ if (hasOverride) {
+ ConformancePlan plan = conformancePlannerOverride.plan(canonicalRoot, resolvedRoot, changedPathRecords);
+ String originScope = originScopeForGeneratedUpdate(records);
+ TypeGeneralizationPolicyResolver.enforceScopeBoundary(originScope,
+ plan.changedPaths());
+ TypeGeneralizationPolicyResolver.enforce(conformanceEngine, plan.root(), plan.changedPaths(), originScope);
+ return plan;
+ }
+ try {
+ ConformancePlan plan = conformanceEngine.planGeneralization(canonicalRoot, resolvedRoot, changedPaths);
+ String originScope = originScopeForGeneratedUpdate(records);
+ TypeGeneralizationPolicyResolver.enforceScopeBoundary(originScope,
+ plan.changedPaths());
+ TypeGeneralizationPolicyResolver.enforce(conformanceEngine, plan.root(), plan.changedPaths(), originScope);
+ return plan;
+ } catch (ProcessorFailureException ex) {
+ throw ex;
+ } catch (RuntimeException ex) {
+ throw new ProcessorFailureException(ProcessorErrorCategory.GeneralizationNoValidType,
+ "GeneralizationNoValidType: " + ex.getMessage(),
+ ex);
+ }
}
private boolean hasTypedNodeBetweenOriginAndPath(FrozenNode resolvedRoot, String originScope, String changedPath) {
@@ -129,7 +189,10 @@ private String parentPointer(String pointer) {
}
private List buildUpdates(List records,
- FrozenNode finalResolvedRoot) {
+ FrozenNode preConformanceResolvedRoot,
+ FrozenNode finalResolvedRoot,
+ List generatedPaths,
+ boolean includeGeneratedUpdates) {
List updates = new ArrayList<>();
ImmutablePatchPlanner finalResolvedPlanner = ImmutablePatchPlanner.forFrozen(finalResolvedRoot);
for (BatchPatchRecord record : records) {
@@ -147,9 +210,27 @@ private List buildUpdates(List records) {
+ return records.isEmpty() ? "/" : records.get(0).originScope();
+ }
+
private boolean hasLaterOverlappingPatch(List records, BatchPatchRecord current) {
int currentIndex = records.indexOf(current);
for (int i = currentIndex + 1; i < records.size(); i++) {
@@ -161,21 +242,13 @@ private boolean hasLaterOverlappingPatch(List records, BatchPa
}
private boolean pathsOverlap(String first, String second) {
- return first.equals(second)
- || isAncestorPath(first, second)
- || isAncestorPath(second, first);
- }
-
- private boolean isAncestorPath(String ancestor, String descendant) {
- if ("/".equals(ancestor)) {
- return !"/".equals(descendant);
- }
- return descendant.startsWith(ancestor + "/");
+ return PointerUtils.descendantOrEqual(first, second)
+ || PointerUtils.descendantOrEqual(second, first);
}
private boolean isProcessorManagedConformanceBypass(ImmutablePatchPlanner.PatchPlan result) {
String relativePath = PointerUtils.relativizePointer(result.originScope(), result.path());
String initialized = ProcessorPointerConstants.RELATIVE_INITIALIZED;
- return relativePath.equals(initialized) || relativePath.startsWith(initialized + "/");
+ return PointerUtils.descendantOrEqual(relativePath, initialized);
}
}
diff --git a/src/main/java/blue/language/processor/ChannelRunner.java b/src/main/java/blue/language/processor/ChannelRunner.java
index 211975a..eedde8b 100644
--- a/src/main/java/blue/language/processor/ChannelRunner.java
+++ b/src/main/java/blue/language/processor/ChannelRunner.java
@@ -44,6 +44,12 @@ void runExternalChannel(String scopePath,
ProcessorEngine.ChannelMatch match;
try {
match = ProcessorEngine.evaluateChannel(owner, channel, bundle, scopePath, event);
+ } catch (RuntimeException ex) {
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.InternalProcessorError),
+ execution.fatalReason(ex, "Channel execution failed"));
+ return;
} finally {
metrics.addChannelMatchNanos(System.nanoTime() - channelMatchStart);
}
@@ -55,25 +61,54 @@ void runExternalChannel(String scopePath,
return;
}
Node eventForHandlers = match.eventNode() != null ? match.eventNode() : event;
- Node checkpointEvent = event != null ? event.clone() : null;
+ Node checkpointEvent = event;
long checkpointStart = System.nanoTime();
- checkpointManager.ensureCheckpointMarker(scopePath, bundle);
- CheckpointManager.CheckpointRecord checkpoint = checkpointManager.findCheckpoint(bundle, channel.key());
- String eventSignature = match.eventId != null
- ? match.eventId
- : ProcessorEngine.canonicalSignature(checkpointEvent);
- if (checkpointManager.isDuplicate(checkpoint, eventSignature)) {
+ CheckpointManager.CheckpointRecord checkpoint;
+ String eventSignature;
+ try {
+ long ensureStart = System.nanoTime();
+ checkpointManager.ensureCheckpointMarker(scopePath, bundle);
+ metrics.addCheckpointEnsureNanos(System.nanoTime() - ensureStart);
+ long findStart = System.nanoTime();
+ checkpoint = checkpointManager.findCheckpoint(bundle, channel.key());
+ metrics.addCheckpointFindNanos(System.nanoTime() - findStart);
+ long identityStart = System.nanoTime();
+ eventSignature = eventSignature(event);
+ metrics.addCheckpointCurrentIdentityNanos(System.nanoTime() - identityStart);
+ } catch (RuntimeException ex) {
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError),
+ execution.fatalReason(ex, "Checkpoint error"));
return;
}
- ChannelCheckpointContext checkpointContext = new ChannelCheckpointContext(scopePath,
- channel.key(),
- checkpointEvent,
- eventSignature,
- checkpoint != null ? checkpoint.lastEventNode : null,
- checkpoint != null ? checkpoint.lastEventSignature : null,
- bundle.markers());
- if (!match.processor.isNewerEvent(contract, checkpointContext)) {
+ boolean newer;
+ long isNewerStart = System.nanoTime();
+ try {
+ ChannelCheckpointContext checkpointContext = new ChannelCheckpointContext(scopePath,
+ channel.key(),
+ checkpointEvent,
+ eventSignature,
+ checkpoint != null ? checkpoint.lastEventNode : null,
+ checkpoint != null ? checkpoint.lastEventSignature : null,
+ bundle.markers());
+ newer = match.processor.isNewerEvent(contract, checkpointContext);
+ } finally {
+ metrics.addCheckpointIsNewerNanos(System.nanoTime() - isNewerStart);
+ }
+ if (!newer) {
+ metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
+ return;
+ }
+ boolean duplicate;
+ long duplicateStart = System.nanoTime();
+ try {
+ duplicate = checkpointManager.isDuplicate(checkpoint, eventSignature);
+ } finally {
+ metrics.addCheckpointDuplicateNanos(System.nanoTime() - duplicateStart);
+ }
+ if (duplicate) {
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
return;
}
@@ -83,7 +118,15 @@ void runExternalChannel(String scopePath,
return;
}
long checkpointPersistStart = System.nanoTime();
- checkpointManager.persist(scopePath, bundle, checkpoint, eventSignature, checkpointEvent);
+ try {
+ checkpointManager.persist(scopePath, bundle, checkpoint, eventSignature, checkpointEvent);
+ } catch (RuntimeException ex) {
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError),
+ execution.fatalReason(ex, "Checkpoint error"));
+ }
+ metrics.addCheckpointPersistNanos(System.nanoTime() - checkpointPersistStart);
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart);
}
@@ -94,8 +137,22 @@ private void runDeliveries(String scopePath,
ProcessorEngine.ChannelMatch match) {
ProcessingMetricsSink metrics = owner.metricsSink();
long checkpointEnsureStart = System.nanoTime();
- checkpointManager.ensureCheckpointMarker(scopePath, bundle);
- String fallbackSignature = ProcessorEngine.canonicalSignature(checkpointEvent);
+ String fallbackSignature;
+ try {
+ long ensureStart = System.nanoTime();
+ checkpointManager.ensureCheckpointMarker(scopePath, bundle);
+ metrics.addCheckpointEnsureNanos(System.nanoTime() - ensureStart);
+ long identityStart = System.nanoTime();
+ fallbackSignature = eventSignature(checkpointEvent);
+ metrics.addCheckpointCurrentIdentityNanos(System.nanoTime() - identityStart);
+ } catch (RuntimeException ex) {
+ metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointEnsureStart);
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError),
+ execution.fatalReason(ex, "Checkpoint error"));
+ return;
+ }
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointEnsureStart);
for (ChannelDelivery delivery : match.deliveries()) {
if (execution.isScopeInactive(scopePath)) {
@@ -105,29 +162,47 @@ private void runDeliveries(String scopePath,
? delivery.checkpointKey()
: channel.key();
long checkpointStart = System.nanoTime();
+ long findStart = System.nanoTime();
CheckpointManager.CheckpointRecord checkpoint = checkpointManager.findCheckpoint(bundle, checkpointKey);
- String eventSignature = delivery.eventId() != null ? delivery.eventId() : fallbackSignature;
- if (checkpointManager.isDuplicate(checkpoint, eventSignature)) {
- metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
- continue;
- }
+ metrics.addCheckpointFindNanos(System.nanoTime() - findStart);
+ long identityStart = System.nanoTime();
+ String eventSignature = eventSignature(checkpointEvent, fallbackSignature);
+ metrics.addCheckpointCurrentIdentityNanos(System.nanoTime() - identityStart);
Boolean shouldProcess = delivery.shouldProcess();
if (Boolean.FALSE.equals(shouldProcess)) {
continue;
}
if (shouldProcess == null) {
- ChannelCheckpointContext checkpointContext = new ChannelCheckpointContext(scopePath,
- checkpointKey,
- checkpointEvent,
- eventSignature,
- checkpoint != null ? checkpoint.lastEventNode : null,
- checkpoint != null ? checkpoint.lastEventSignature : null,
- bundle.markers());
- if (!match.processor.isNewerEvent(channel.contract(), checkpointContext)) {
+ boolean newer;
+ long isNewerStart = System.nanoTime();
+ try {
+ ChannelCheckpointContext checkpointContext = new ChannelCheckpointContext(scopePath,
+ checkpointKey,
+ checkpointEvent,
+ eventSignature,
+ checkpoint != null ? checkpoint.lastEventNode : null,
+ checkpoint != null ? checkpoint.lastEventSignature : null,
+ bundle.markers());
+ newer = match.processor.isNewerEvent(channel.contract(), checkpointContext);
+ } finally {
+ metrics.addCheckpointIsNewerNanos(System.nanoTime() - isNewerStart);
+ }
+ if (!newer) {
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
continue;
}
}
+ boolean duplicate;
+ long duplicateStart = System.nanoTime();
+ try {
+ duplicate = checkpointManager.isDuplicate(checkpoint, eventSignature);
+ } finally {
+ metrics.addCheckpointDuplicateNanos(System.nanoTime() - duplicateStart);
+ }
+ if (duplicate) {
+ metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
+ continue;
+ }
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart);
Node eventForHandlers = delivery.eventForDelivery();
if (eventForHandlers == null) {
@@ -138,11 +213,28 @@ private void runDeliveries(String scopePath,
return;
}
long checkpointPersistStart = System.nanoTime();
- checkpointManager.persist(scopePath, bundle, checkpoint, eventSignature, checkpointEvent);
+ try {
+ checkpointManager.persist(scopePath, bundle, checkpoint, eventSignature, checkpointEvent);
+ } catch (RuntimeException ex) {
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError),
+ execution.fatalReason(ex, "Checkpoint error"));
+ return;
+ }
+ metrics.addCheckpointPersistNanos(System.nanoTime() - checkpointPersistStart);
metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart);
}
}
+ private String eventSignature(Node fallbackEvent) {
+ return eventSignature(fallbackEvent, null);
+ }
+
+ private String eventSignature(Node fallbackEvent, String fallbackSignature) {
+ return fallbackSignature != null ? fallbackSignature : checkpointManager.eventIdentity(fallbackEvent);
+ }
+
void runHandlers(String scopePath,
ContractBundle bundle,
String channelKey,
@@ -160,6 +252,8 @@ void runHandlers(String scopePath,
break;
}
HandlerMatchContext matchContext = new HandlerMatchContext(scopePath,
+ handler.key(),
+ channelKey,
event,
bundle.markers(),
owner.matchingService());
@@ -186,6 +280,21 @@ void runHandlers(String scopePath,
long executionStart = System.nanoTime();
try {
ProcessorEngine.executeHandler(owner, handler.contract(), context);
+ context.applyBufferedEffects();
+ } catch (RunTerminationException ex) {
+ throw ex;
+ } catch (ProcessorFatalException ex) {
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ ex.errorCategory(),
+ execution.fatalReason(ex, "Handler execution failed"));
+ break;
+ } catch (RuntimeException ex) {
+ execution.enterFatalTermination(scopePath,
+ bundle,
+ execution.fatalCategory(ex, ProcessorErrorCategory.HandlerExecutionError),
+ execution.fatalReason(ex, "Handler execution failed"));
+ break;
} finally {
metrics.addHandlerExecutionNanos(System.nanoTime() - executionStart);
}
diff --git a/src/main/java/blue/language/processor/CheckpointIdentityCache.java b/src/main/java/blue/language/processor/CheckpointIdentityCache.java
new file mode 100644
index 0000000..de9a87c
--- /dev/null
+++ b/src/main/java/blue/language/processor/CheckpointIdentityCache.java
@@ -0,0 +1,87 @@
+package blue.language.processor;
+
+import blue.language.Blue;
+import blue.language.model.Node;
+import blue.language.processor.model.ChannelEventCheckpoint;
+
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+final class CheckpointIdentityCache {
+ private final Blue blue;
+ private final ProcessingMetricsSink metrics;
+ private final IdentityHashMap eventIdentities = new IdentityHashMap<>();
+ private final Map storedIdentities = new LinkedHashMap<>();
+
+ CheckpointIdentityCache(Blue blue, ProcessingMetricsSink metrics) {
+ this.blue = blue;
+ this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP;
+ }
+
+ String identity(Node event) {
+ if (event == null) {
+ return null;
+ }
+ if (eventIdentities.containsKey(event)) {
+ metrics.incrementCheckpointIdentityCacheHits();
+ return eventIdentities.get(event);
+ }
+ metrics.incrementCheckpointIdentityCacheMisses();
+ String identity = CheckpointIdentityCalculator.identity(event, blue, metrics);
+ eventIdentities.put(event, identity);
+ return identity;
+ }
+
+ String storedIdentity(ChannelEventCheckpoint checkpoint, String channelKey, Node event) {
+ if (checkpoint == null || event == null) {
+ return null;
+ }
+ StoredCheckpointKey key = new StoredCheckpointKey(checkpoint, channelKey);
+ if (storedIdentities.containsKey(key)) {
+ metrics.incrementCheckpointStoredIdentityCacheHits();
+ return storedIdentities.get(key);
+ }
+ metrics.incrementCheckpointStoredIdentityCacheMisses();
+ String identity = CheckpointIdentityCalculator.identity(event, blue, metrics);
+ storedIdentities.put(key, identity);
+ return identity;
+ }
+
+ void updateStoredIdentity(ChannelEventCheckpoint checkpoint, String channelKey, String identity) {
+ if (checkpoint == null) {
+ return;
+ }
+ storedIdentities.put(new StoredCheckpointKey(checkpoint, channelKey), identity);
+ }
+
+ private static final class StoredCheckpointKey {
+ private final ChannelEventCheckpoint checkpoint;
+ private final String channelKey;
+
+ private StoredCheckpointKey(ChannelEventCheckpoint checkpoint, String channelKey) {
+ this.checkpoint = checkpoint;
+ this.channelKey = channelKey;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof StoredCheckpointKey)) {
+ return false;
+ }
+ StoredCheckpointKey that = (StoredCheckpointKey) other;
+ return checkpoint == that.checkpoint
+ && (channelKey != null ? channelKey.equals(that.channelKey) : that.channelKey == null);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = System.identityHashCode(checkpoint);
+ result = 31 * result + (channelKey != null ? channelKey.hashCode() : 0);
+ return result;
+ }
+ }
+}
diff --git a/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java b/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java
new file mode 100644
index 0000000..e49de45
--- /dev/null
+++ b/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java
@@ -0,0 +1,53 @@
+package blue.language.processor;
+
+import blue.language.Blue;
+import blue.language.model.Node;
+import blue.language.utils.BlueIdCalculator;
+
+final class CheckpointIdentityCalculator {
+
+ private CheckpointIdentityCalculator() {
+ }
+
+ static String identity(Node event) {
+ return identity(event, null);
+ }
+
+ static String identity(Node event, Blue blue) {
+ return identity(event, blue, ProcessingMetricsSink.NOOP);
+ }
+
+ static String identity(Node event, Blue blue, ProcessingMetricsSink metrics) {
+ if (event == null) {
+ return null;
+ }
+ ProcessingMetricsSink sink = metrics != null ? metrics : ProcessingMetricsSink.NOOP;
+ long directStart = System.nanoTime();
+ try {
+ String identity = BlueIdCalculator.calculateBlueId(event);
+ sink.addCheckpointDirectBlueIdNanos(System.nanoTime() - directStart);
+ return identity;
+ } catch (RuntimeException directFailure) {
+ sink.addCheckpointDirectBlueIdNanos(System.nanoTime() - directStart);
+ if (blue == null) {
+ throw new IllegalStateException(
+ "Checkpoint event identity requires valid BlueId Input or a Blue canonicalization context",
+ directFailure);
+ }
+ long contentStart = System.nanoTime();
+ try {
+ String identity = blue.calculateSemanticBlueId(event.clone());
+ sink.addCheckpointContentBlueIdNanos(System.nanoTime() - contentStart);
+ return identity;
+ } catch (RuntimeException semanticFailure) {
+ sink.addCheckpointContentBlueIdNanos(System.nanoTime() - contentStart);
+ long fallbackStart = System.nanoTime();
+ try {
+ return ProcessorEngine.canonicalSignature(event.clone());
+ } finally {
+ sink.addCheckpointFallbackNanos(System.nanoTime() - fallbackStart);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/blue/language/processor/CheckpointManager.java b/src/main/java/blue/language/processor/CheckpointManager.java
index ba83684..f65a973 100644
--- a/src/main/java/blue/language/processor/CheckpointManager.java
+++ b/src/main/java/blue/language/processor/CheckpointManager.java
@@ -1,8 +1,10 @@
package blue.language.processor;
+import blue.language.Blue;
import blue.language.model.Node;
import blue.language.processor.model.ChannelEventCheckpoint;
import blue.language.processor.model.MarkerContract;
+import blue.language.processor.registry.RuntimeBlueIds;
import blue.language.processor.util.PointerUtils;
import blue.language.processor.util.ProcessorContractConstants;
import blue.language.processor.util.ProcessorPointerConstants;
@@ -18,11 +20,23 @@
final class CheckpointManager {
private final DocumentProcessingRuntime runtime;
- private final Function signatureFn;
+ private final CheckpointIdentityCache identityCache;
- CheckpointManager(DocumentProcessingRuntime runtime, Function signatureFn) {
+ CheckpointManager(DocumentProcessingRuntime runtime) {
+ this(runtime, (Blue) null, ProcessingMetricsSink.NOOP);
+ }
+
+ CheckpointManager(DocumentProcessingRuntime runtime, Blue blue) {
+ this(runtime, blue, ProcessingMetricsSink.NOOP);
+ }
+
+ CheckpointManager(DocumentProcessingRuntime runtime, Blue blue, ProcessingMetricsSink metrics) {
this.runtime = Objects.requireNonNull(runtime, "runtime");
- this.signatureFn = Objects.requireNonNull(signatureFn, "signatureFn");
+ this.identityCache = new CheckpointIdentityCache(blue, metrics);
+ }
+
+ CheckpointManager(DocumentProcessingRuntime runtime, Function ignoredSignatureFn) {
+ this(runtime, (Blue) null, ProcessingMetricsSink.NOOP);
}
void ensureCheckpointMarker(String scopePath, ContractBundle bundle) {
@@ -30,9 +44,8 @@ void ensureCheckpointMarker(String scopePath, ContractBundle bundle) {
String pointer = PointerUtils.resolvePointer(scopePath, ProcessorPointerConstants.RELATIVE_CHECKPOINT);
if (marker == null) {
Node markerNode = new Node()
- .type(new Node().blueId("ChannelEventCheckpoint"))
- .properties("lastEvents", new Node().properties(new LinkedHashMap<>()))
- .properties("lastSignatures", new Node().properties(new LinkedHashMap<>()));
+ .type(new Node().blueId(RuntimeBlueIds.CHANNEL_EVENT_CHECKPOINT))
+ .properties("lastEvents", new Node().properties(new LinkedHashMap<>()));
runtime.directWrite(pointer, markerNode);
bundle.registerCheckpointMarker(new ChannelEventCheckpoint());
return;
@@ -49,8 +62,6 @@ CheckpointRecord findCheckpoint(ContractBundle bundle, String channelKey) {
ChannelEventCheckpoint checkpoint = (ChannelEventCheckpoint) entry.getValue();
Node stored = checkpoint.lastEvent(channelKey);
CheckpointRecord record = new CheckpointRecord(entry.getKey(), checkpoint, channelKey, stored);
- String storedSignature = checkpoint.lastSignature(channelKey);
- record.lastEventSignature = storedSignature != null ? storedSignature : signatureFn.apply(stored);
return record;
}
}
@@ -58,7 +69,15 @@ CheckpointRecord findCheckpoint(ContractBundle bundle, String channelKey) {
}
boolean isDuplicate(CheckpointRecord record, String signature) {
- return record != null && record.matches(signature);
+ if (record == null || signature == null || record.lastEventNode == null) {
+ return false;
+ }
+ if (record.lastEventSignature == null) {
+ record.lastEventSignature = identityCache.storedIdentity(record.checkpoint,
+ record.channelKey,
+ record.lastEventNode);
+ }
+ return record.matches(signature);
}
void persist(String scopePath,
@@ -76,12 +95,12 @@ void persist(String scopePath,
runtime.directWrite(pointer, stored);
record.checkpoint.updateEvent(record.channelKey, stored);
record.lastEventNode = stored != null ? stored.clone() : null;
- String signaturePointer = PointerUtils.resolvePointer(scopePath,
- ProcessorPointerConstants.relativeCheckpointLastSignature(record.markerKey, record.channelKey));
- Node signatureNode = eventSignature != null ? new Node().value(eventSignature) : null;
- runtime.directWrite(signaturePointer, signatureNode);
- record.checkpoint.updateSignature(record.channelKey, eventSignature);
record.lastEventSignature = eventSignature;
+ identityCache.updateStoredIdentity(record.checkpoint, record.channelKey, eventSignature);
+ }
+
+ String eventIdentity(Node event) {
+ return identityCache.identity(event);
}
static final class CheckpointRecord {
diff --git a/src/main/java/blue/language/processor/ConformanceChangedPath.java b/src/main/java/blue/language/processor/ConformanceChangedPath.java
new file mode 100644
index 0000000..2425bf2
--- /dev/null
+++ b/src/main/java/blue/language/processor/ConformanceChangedPath.java
@@ -0,0 +1,25 @@
+package blue.language.processor;
+
+import blue.language.processor.util.PointerUtils;
+
+/**
+ * A patch target and the scope that originated it, used by injected conformance planners.
+ */
+public final class ConformanceChangedPath {
+
+ private final String path;
+ private final String originScope;
+
+ public ConformanceChangedPath(String path, String originScope) {
+ this.path = PointerUtils.normalizePointer(path);
+ this.originScope = PointerUtils.normalizeScope(originScope);
+ }
+
+ public String path() {
+ return path;
+ }
+
+ public String originScope() {
+ return originScope;
+ }
+}
diff --git a/src/main/java/blue/language/processor/ConformancePlannerOverride.java b/src/main/java/blue/language/processor/ConformancePlannerOverride.java
new file mode 100644
index 0000000..0570cbc
--- /dev/null
+++ b/src/main/java/blue/language/processor/ConformancePlannerOverride.java
@@ -0,0 +1,18 @@
+package blue.language.processor;
+
+import blue.language.conformance.ConformancePlan;
+import blue.language.snapshot.FrozenNode;
+
+import java.util.List;
+
+/**
+ * Optional conformance planner hook for isolated conformance harnesses.
+ */
+public interface ConformancePlannerOverride {
+
+ boolean applies();
+
+ ConformancePlan plan(FrozenNode canonicalRoot,
+ FrozenNode resolvedRoot,
+ List changedPaths);
+}
diff --git a/src/main/java/blue/language/processor/ContractEffectBuffer.java b/src/main/java/blue/language/processor/ContractEffectBuffer.java
new file mode 100644
index 0000000..89929f0
--- /dev/null
+++ b/src/main/java/blue/language/processor/ContractEffectBuffer.java
@@ -0,0 +1,136 @@
+package blue.language.processor;
+
+import blue.language.model.Node;
+import blue.language.processor.model.JsonPatch;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+final class ContractEffectBuffer {
+
+ private long gas;
+ private String invalidGasReason;
+ private final List patches = new ArrayList<>();
+ private final List patchBatches = new ArrayList<>();
+ private final List emittedEvents = new ArrayList<>();
+ private TerminationRequest terminationRequest;
+
+ void addGas(long units) {
+ if (units < 0) {
+ invalidGasReason = "Gas amount must be non-negative";
+ return;
+ }
+ gas += units;
+ }
+
+ long gas() {
+ return gas;
+ }
+
+ String invalidGasReason() {
+ return invalidGasReason;
+ }
+
+ void addPatch(JsonPatch patch) {
+ if (patch != null) {
+ addPatches(Collections.singletonList(patch));
+ }
+ }
+
+ void addPatches(List input) {
+ addPatches(input, null);
+ }
+
+ void addPreviewedPatches(List input, WorkingDocument.Preview preview) {
+ addPatches(input, preview);
+ }
+
+ private void addPatches(List input, WorkingDocument.Preview preview) {
+ if (input == null || input.isEmpty()) {
+ return;
+ }
+ List